Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude Fable 5.1 59f69e0ee8 fix(agent): run context compaction on the streaming path
Agent auto-enables a ContextManager whenever tools are present
(auto_compact, threshold 0.8), and the non-streaming chat() path honours
it by calling _apply_context_management between building the messages
and issuing the request. start(stream=True) / iter_stream built the same
messages and sent them unmodified, so a long session streamed its entire
history to the model on every turn while the configured manager sat
idle. The desktop's default agent has tools, so every streamed turn hit
this once the conversation grew.

Both streaming branches now call the same _apply_context_management the
non-streaming path uses: the OpenAI branch on the built messages list
(which the post-tool follow-up request also reuses), and the custom-LLM
branch on the chat_history that is actually handed to
get_response_stream. No new compaction machinery; context=False remains
zero-overhead because the helper returns immediately when no manager is
configured.

The test stubs the transport, seeds 400 long messages (~160k estimated
tokens against a 128k window) and asserts the message list that reaches
the transport is bounded on both branches, plus a context=False control
that pins the full history going out untouched. Removing either call
makes the corresponding test fail.

Closes #4714

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-02 23:17:19 +01:00
94a225283e fix(parity): make the Python<->TS parity tracker a gate, and check signatures (#4706)
* fix(parity): make the Python<->TS parity tracker a gate, and check signatures

The exported-name tracker only ever regenerated and auto-committed; its
--check mode could not pass off the GitHub runner (absolute paths in the
JSON), 26 of its last 30 commits changed only the date, `export *` barrels
were invisible, and a shim module written to satisfy name matching counted
as "done" for 56 features. Rust reported 162% parity beside 129 gaps.

Names layer (generator.py, typescript_extractor.py):
- repo-relative paths; lastUpdated preserved when content is unchanged
- export * resolution, camelCase fallback, TS alias table, one presence
  rule shared by summary and gap matrix
- rows provided only by src/parity/index.ts are STUB, not DONE
- Rust status/percentage derived from the gap matrix
- personal fallback path replaced by _paths.find_repo_root (raises)
- Rust file scan sorted so output is stable across OSes

Signatures layer (new _dev/parity/signatures/):
- surface.yaml (10 curated surfaces), rules.yaml (case, aliases,
  flattening, default equivalences), waivers.yaml (168 baseline entries)
- py_extract.py (AST only), ts_extract.mjs (TypeScript compiler API,
  exit 2 when the compiler is missing, never an empty pass)
- compare.py: --write/--check/--diff/--baseline; fails on un-waived
  drift, expired or stale waivers, or a run that compared nothing
- generates src/praisonai-ts/SIGNATURE_PARITY.md + signature-parity.json

CI:
- parity-gate.yml: two named PR jobs (names, signatures) that fail on drift
- update-parity-tracker.yml: fixed Rust path filter, added generator and
  CLI feature paths; still regenerates on push to main

Tests: 19 -> 110, each behavioural test paired with a control.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* feat(parity): flatten options-object members into method signatures; add --prune

A TS method that takes an options interface (chat(prompt, options?: AgentChatOptions))
now reports the interface members as parameters, with defaults read from
options.x ?? v in the body, so Python keyword arguments map one to one.
--prune removes waivers whose gap has been closed.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* fix(parity): read LLM/tool ctor defaults; treat Python None as equivalent to an empty TS literal

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* fix(parity): waive tool().description default with its reason; regenerate report

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 23:07:39 +01:00
praisonai-triage-agent[bot]andMervinPraison e3e6b8621e fix: give desktop projects real context via per-project instructions (fixes #4697) (#4711)
Projects were a sidebar label the /chat route never read. Add a per-project
instructions store (projects.json), wire the previously-dead GET /projects to
report it plus a POST /projects to edit it, and have /chat prepend a chat's
project instructions to the turn (not the persisted prompt). Sidebar project
labels are now clickable to edit those instructions.

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-09-02 23:05:22 +01:00
MervinPraison 45b440f243 docs: auto-update feature parity trackers [skip ci] 2026-09-02 22:03:36 +00:00
MervinPraison 0eca1000b9 docs: auto-update feature parity trackers [skip ci] 2026-09-02 21:57:53 +00:00
praisonai-triage-agent[bot]andMervinPraison 24a4b71c94 fix: interpret naive scheduler 'at' timestamps as local time (fixes #4691) (#4705)
is_due() stamped naive one-shot 'at' timestamps as UTC, so in a non-UTC
zone a job typed from local wall-clock fired an hour (or more) off and
test_update_cadence_resets_last_run failed. Naive 'at' now resolves to the
schedule tz / PRAISONAI_SCHEDULE_TIMEZONE when set, else the local zone.

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-09-02 22:55:31 +01:00
Mervin PraisonandClaude Fable 5.1 b43db27879 fix(scheduler): declare croniter so daily and weekday schedules actually fire (#4690)
Every cron-kind schedule was accepted, persisted, and never ran. croniter was
declared in none of the nine pyproject.toml files, and is_due() answers False
for a cron job when it cannot import the engine -- a log line, no error at
creation, a job that never fires.

parse_schedule() converts every natural-language recurring form to cron, so
"every day at 9am" and "weekdays at 9am" -- the schedules people actually
write -- were in the broken half. Interval ("hourly") and one-shot ("in 20
minutes") never needed the engine and were fine, which is how this went
unnoticed: the simplest test cases passed.

Two changes, one per layer of the failure:

  * croniter>=6.0 joins the core dependencies (pure Python; pulls only
    python-dateutil), so a default install can evaluate what it accepts.
  * parse_schedule() refuses a cron schedule when the engine is absent,
    raising ValueError with the install hint. Both call sites route through
    one helper. The failure moves from "silently at fire time" to "loudly at
    creation", which is the only moment the user can act on it.

Tests assert the effect -- a daily job is due eight days after creation --
and pin the packaging by reading pyproject.toml, since packaging, not code,
was the defect. All four mutations killed: undeclaring the dependency, a guard
that never raises, and unguarding either call site on its own.

Not touched: test_update_cadence_resets_last_run fails on main independently
of this change, on any non-UTC machine -- naive `at` timestamps are stamped
UTC by is_due() while the test (and any user) means local time. Filed
separately.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-02 22:55:14 +01:00
praisonai-triage-agent[bot]andMervin Praison d944fcc8fe fix: show installed skills in interactive / menu (#4703)
* fix: show installed skills in interactive / menu (fixes #4692)

Pass include_skills=True when building the command registry in both
interactive surfaces (REPL and TUI), so discovered skills appear in the
`/` completion menu and dispatch like custom commands.

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

* fix: make interactive skills runnable and discoverable in / menu

Skills were registered in the interactive command registry but had no
handler, so both dispatchers fell through to "Unknown command"; and the
completion menus were fed only built-ins, so skills never appeared after /.

- Give SkillCommandSource a handler that renders the SKILL.md body + args
  into a prompt (mirrors CustomCommandSource), so /name actually runs.
- Feed discovered registry commands into the REPL completer (warmed at
  startup) and the async TUI completer, so installed skills show after /.
- Add regression test asserting the skill handler yields a runnable prompt.

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-09-02 22:54:43 +01:00
ac984816c6 test: guard lazy-command module paths in praisonai-code CLI (#4704)
* test: guard lazy-command module paths in praisonai-code CLI (fixes #4693)

Add a regression test that imports every non-resident module named in
``_LAZY_COMMANDS`` (plus ``_SPECIAL_COMMANDS``) so a dangling ``.commands.*``
path fails in CI rather than at first use. Wrapper/bot/train/browser/mcp/
deploy-resident commands keep placeholder paths re-routed at invocation time
and are excluded, matching runtime dispatch.

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

* test: cover resident lazy-command re-routes (incl. schedule) in CLI guard

Adds a conditional resident-command test mirroring get_command()'s dispatch:
when the owning package (praisonai/praisonai_bot/train/browser/mcp/deploy) is
installed, it imports the absolute .cli.commands.<name> target and asserts the
attribute exists; it skips gracefully in standalone praisonai-code. This closes
the untested schedule -> praisonai.cli.commands.schedule re-route gap (#4693).

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 22:54:23 +01:00
fccb0f24aa fix(mobile): a corrected engine address takes effect, and a refused one is said (#4694)
* fix(mobile): a corrected engine address takes effect, and a refused one is said

The recovery path a phone depends on is: reach no 127.0.0.1:8765, open
Settings, type the engine's real address. #4685 (refs #4673) made the fields
editable, but the path still ended short of a working engine in two places:

1. The write persisted and the engine ignored it. `enginesFor` read `baseUrl`
   once, at construction, and both the remote-http engine and its health probe
   closed over that string. The app builds its engine exactly once at boot, so
   the corrected address was stored, displayed, and never used until the app
   was force-quit. `RemoteHttpOptions.baseUrl` now also accepts a resolver and
   the registry passes one, so the NEXT message and the next probe go where the
   user just said. A plain string still works; rebuilding the engine on change
   was rejected because `createRunController` has no seam to swap it and would
   drop the turn in flight.

2. A refused value vanished. `settings.set` returning false, or `validateInput`
   returning null, reset the field in silence -- indistinguishable from a
   mis-tap or a save that worked, on the one screen whose job is repair. The
   refusal is now written beside the field (a `role="alert"` node that exists
   from first paint, so it is announced reliably) and into the assertive live
   region, naming the setting. Only that key's note is touched: an accepted
   write clears its own message and leaves another setting's refusal standing.

Committing a field also moved out of a per-field `change` listener into
`intents.ts` as `set-setting` and a root-delegated `change` handler, like every
tap. Settings was the one affordance that bypassed `intentFrom`, so the
decisions (which key, what was typed, whether this element is a field at all)
could only be tested by synthesising DOM events. `Actionable.value` is absent
on non-fields on purpose: a missing value means "not a field", never "cleared".

The fake DOM gains a bubbling `change`, because a dispatch on the field alone
passes against a per-field listener and reports nothing against a delegated
one. Tests pin each link: the next /chat and /health go to the corrected
address without a relaunch, a refusal is shown and spoken, an accepted write
clears only its own note, and the decoder refuses a keyless or valueless
set-setting.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* fix(mobile): pin a run's control requests to the engine that owns its id

A mid-turn address change redirected /cancel and /approve to the newly
typed address, but the runId/approvalId was minted by the engine the run
started at -- the new engine refuses a foreign id, and controller.ts only
aborts the local reader once cancel returns true, so the original turn kept
generating while the user believed they had stopped it. Pin each run's
origin base and route its control requests there, falling back to the live
resolver only for an id this instance never saw.

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

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 22:47:30 +01:00
MervinPraison 709895066f docs: auto-update documentation parity trackers [skip ci] 2026-09-02 21:44:15 +00:00
51e13c33c0 fix(parity): the drift gate was blind, and the tracker could not fail (#4689)
* fix(parity): the drift gate was blind, and the tracker could not fail

Three agents audited how Python<->TypeScript parity is managed. The findings
below are each reproduced; the fixes are the ones that need no design
decision. What the tracker MEASURES is a separate problem, reported
separately.

THE ONE REAL DRIFT GATE NEVER FIRED ON DRIFT. `mobile.yml` runs
`check-upstream-parity`, which typechecks the real praisonai-ts `Agent`
against the structural contract in `agent-api.ts` -- the only mechanism in the
repo that fails a build on upstream signature change. Its path filter watched
`src/praisonai-mobile/**` only, so it fired when the CONSUMER changed and was
silent when the thing it watches did. Verified against 71197fa80, which edited
`src/praisonai-ts/src/agent/simple.ts`: no parity check ran on that commit. It
now watches `src/praisonai-ts/src/**` too.

`--check` FAILED ON EVERY MACHINE THAT IS NOT THE RUNNER. The generator
embedded `str(self.repo_root / ...)` in four places, so the committed JSON
carried `/home/runner/work/PraisonAI/...` and the field flipped between that
and `/Users/praison/...` across commits. Made repo-relative; `--check` now
exits 0 locally for the first time.

THE TRACKER COULD NOT REPORT FAILURE. Both extractors swallow their errors and
return empty, and every number downstream is a set difference -- so an
unparseable `__init__.py` produced `{'pythonCoreFeatures': 0, 'gapCount': 0}`
and exit 0. CI would have committed that as "no gaps". Zero exports is now
refused with a non-zero exit, and the trackers are left untouched. Three
existing fixtures built exactly that empty tree; they now declare one export,
because an empty tree was never a state worth generating from.

RUST REPORTED 162.3% PARITY, "PARITY_ACHIEVED", WITH 129 GAPS. `parityPercentage`
divided total Rust exports by Python's -- volume, not parity -- and the status
fired whenever the crate exported more names, however much was missing. The
`PARITY.md` from the same run said 69.8%. Now implemented/expected (68.6%) and
`PARITY_ACHIEVED` requires zero gaps.

RUST NEVER TRIGGERED EITHER WORKFLOW. Both filtered on
`src/praisonai-rust/src/**`, which does not exist -- the crate is at
`src/praisonai-rust/praisonai/src/`. The Rust numbers only refreshed
incidentally, on an unrelated push. Also added the generator's own inputs
(`_dev/parity/**`, `cli/features/**`), which it reads and neither workflow
watched.

`--check` IS NOW A GATE. It has existed since the script was written, exits 1
on staleness, and was invoked by nothing. A `pull_request` job runs it; the
committing job is restricted to `push`, since regenerating onto a
contributor's branch is not this workflow's business and would fail on a fork.

This checks STALENESS, not parity. What "DONE" means is unchanged and remains
what the tracker's own disclaimer says it is.

22 tests pass in test_parity_tracker.py, including three new ones covering the
refusal and its pair.

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

* fix(parity): regenerate stale Rust tracker so the new --check gate passes

The committed Rust FEATURE_PARITY_TRACKER.json lagged the crate's current
module layout (ToolDefinition and AutoRagConfig had moved), so the very
staleness gate this PR introduces failed on HEAD. Regenerated from source;
status/percentage stay consistent (NEAR_PARITY, 68.6%).

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

* fix(parity): the new --check gate needs pyyaml, like the push job

Its first run failed in 8s: `ModuleNotFoundError: No module named 'yaml'`.
The generator imports pyyaml; the committing job installs it and the new
checking job did not. Same step, copied.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 22:40:59 +01:00
MervinPraison 667706adcf docs: auto-update feature parity trackers [skip ci] 2026-09-02 20:57:25 +00:00
praisonai-triage-agent[bot]andMervin Praison 8e3f5c7f94 fix(mobile): make Settings editable so a phone can reach its engine (#4685)
* fix(mobile): make Settings editable so a phone can reach its engine (fixes #4673)

The device default is remote-http at http://127.0.0.1:8765, which a phone
cannot reach; it stays the default because the in-process engine's module is
absent from the shipping webview, so defaulting to it would swap a recoverable
warning for an unrecoverable "unavailable in this build". The one recovery the
remote default needs -- change baseUrl in Settings -- did not exist: every row
rendered as a read-only <span> and facade.set / validateInput had no caller.

buildSettingsScreen now renders value rows as <input>/<select> wired through the
existing pure validateInput to facade.set. A refused write resets the field to
the stored value. Secret rows stay presence-only. No new module, param, export,
or default.

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

* fix(mobile): a failed settings write stays local, not a fatal crash

A settings edit persists through StoragePort, which rejects on a real
device (SecurityError with site data blocked, QuotaExceededError). The
fire-and-forget `void commit(...)` let that rejection float to the global
crash handler, replacing the whole app with the fatal screen -- and the
store had already committed the value to memory before persisting, so the
field showed an address the next launch would never read.

- settingControl.commit now catches a rejected `set` and resets the field
- store.set persists BEFORE mutating memory, rolling back on failure so a
  refused write leaves no phantom value and marks no key chosen
- regression tests: a storage failure while editing stays local (main),
  and a write whose persist fails rolls back and wakes nobody (store)

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 21:56:56 +01:00
praisonai-triage-agent[bot]andMervin Praison fe08ef03f5 fix: route bare model + base_url as OpenAI-compatible (#4684)
* fix: route bare model + base_url as OpenAI-compatible (fixes #4535)

A bare catalog model id (no provider/ prefix) with a custom base_url
(e.g. llm="deepseek-v4-flash", base_url=".../v1") caused LiteLLM to raise
"LLM Provider NOT provided". Prefix openai/ at request-build time so it
routes through the OpenAI-compatible client, leaving anthropic/gemini/
ollama models untouched.

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

* fix: route bare model through openai/ when subscription base_url is set

Subscription auth providers (qwen-cli, codex) inject an OpenAI-compatible
base_url at request time. _resolve_openai_compatible_model() previously only
checked self.base_url, so a bare model under subscription auth reached LiteLLM
unprefixed and raised "LLM Provider NOT provided". Now consider the effective
base_url (instance or subscription-supplied). Addresses Greptile review.

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 21:56:43 +01:00
Mervin Praison b3d9cc008e fix(mobile): the unresolvable-import check flagged installed packages too (#4686)
A defect I shipped in #4678. The check asked "did this bare import stay
external?" -- which is true of EVERY bare import, because the plugin in this
same file externalises them all on purpose so Node builtins surface in the
metafile instead of being silently shimmed.

So it reported installed, perfectly resolvable packages as unresolvable. It
passed only because the shipped bundle happens to have no bare imports at all,
and it would have blocked the first real dependency the app ever added.

Caught while testing `[email protected]`, published today with the `./mobile`
entry the in-process engine needs: the gate reported `praisonai/mobile`
unresolvable while `createRequire` resolved it to
node_modules/praisonai/dist/mobile.js.

It now asks the question it claims to ask -- can the specifier be RESOLVED
from the entry -- so an installed package passes and a missing one still
fails. Both directions are tested; the first version had only the failing one,
which is why the false positive went unnoticed.

The probe for the passing direction lives inside the package rather than a
temp directory, because resolution is relative to the entry: a temp-dir entry
has no node_modules above it, so everything looks missing and the test passes
for the wrong reason. That was the first draft, and it failed for exactly that
reason before being corrected.

Three mutations; two die. The third -- dropping the Node-builtin exemption --
is EQUIVALENT and now says so in the source: createRequire resolves builtins,
verified, so the line is intent rather than behaviour.

1327 tests on Node 22.23 and 24.12.
2026-09-02 21:55:41 +01:00
praisonai-triage-agent[bot]andMervin Praison a07b512646 fix(mobile): route the coalescer flush-tick publish through the publish gate (#4687)
* fix(mobile): route the coalescer flush-tick publish through the gate (#4640, #4639)

The publish gate (core/src/pacing/publish-gate.ts) carried two mobile-tuned
constants (MAX_HELD_CHARS=96, UNPAINTED_REOPEN_MS=200) but was unreachable in
the shipping pipeline: the coalescer's flush tick drains every maxDelayMs, so
`coalescer.push()` in the run loop almost always returned [] and the per-event
gate was never consulted, while the tick's own publish was ungated. A module
with tuned constants that never runs reads as load-bearing (audit #4639).

Wire the tick publish through the gate. `gate()` is TRUE whenever the gate is
open, and it is open exactly when nothing has painted recently, so the tick
after a quiet period still paints immediately; the gate only skips a tick that
fires just after a paint, which is the backpressure path this pipeline lacked.
MAX_HELD_CHARS still forces the tail out and the final finally-publish stays
ungated, so no token is lost.

Pinned by a controller test: 40 ticks with no frame release produce a handful
of paints, not 40 (reverting to the ungated tick gives 45/40 and fails).
gaps.md updated in the same commit per its own close-it-here rule.

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

* fix(mobile): release backpressure so a gate-rejected tick cannot strand text (#4687)

The flush tick drains the coalescer before the publish gate is consulted, so a
frame the gate rejects is already gone from the coalescer -- its text is on the
transcript but unpainted. If the stream then pauses, no later tick sees pending
text and gate reopening does not itself publish, leaving already-received text
invisible until the next delta or the final publish (Greptile P1).

Track how far the screen has actually painted (paintedChars) and, on a tick that
finds nothing new to drain, flush that stranded progress once the gate reopens.
Adds a regression test (positive control: fails 'hello ' vs 'hello world'
without the fix) and documents the catch-up in gaps.md.

typecheck ✓, boundaries ✓ (143 files), 1328 tests 0 failed (+1).

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

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 21:55:38 +01:00
praisonai-triage-agent[bot]andMervin Praison de60cc9455 docs(examples): meeting-notes agent from existing primitives (#4683)
* docs(examples): add meeting-notes agent example composed from existing primitives (#4622)

Demonstrates the AI meeting-notes pipeline (transcribe -> summarize ->
index for Q&A) using existing AudioAgent, Agent, and knowledge= RAG.
No new SDK surface added; keeps core lightweight per AGENTS.md.

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

* fix(examples): print final Q&A result in meeting-notes agent

Greptile P1: qa.start() return was discarded so non-interactive/redirected
runs omitted the advertised Q&A output. Now printed explicitly.

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 21:45:39 +01:00
bef31f9258 fix(mobile): the app could not reach an engine, type into itself, or parse on its own WebView floor (#4678)
* fix(mobile): the app could not reach an engine, type into itself, or parse on its own WebView floor

Three agents audited the package against one question -- what stops this being
an installable iOS/Android app. Every finding below was reproduced before it
was fixed, and every fix is re-run against the mutation that reverts it.

THE APP COULD NOT REACH ANY ENGINE. `connect-src 'self' ipc:
http://ipc.localhost` permitted no engine address at all, while `baseUrl` is a
user setting that accepts any host. Under Tauri the HttpPort is still browser
`fetch`, so a webview enforces connect-src on it: every request was blocked
before a packet left, including the shipped default. Proved by serving dist/
twice, once with the shipped CSP and once without -- "Refused to connect
because it violates the document's Content Security Policy". The desktop app
already carried the loopback entries; mobile also needs https:, because on a
phone the engine is across a network. script-src stays closed, and a test pins
that it does.

THE COMPOSER SAT UNDER THE KEYBOARD. `createTauriShell` had
`keyboardHeightPx = 0` whose only writer was the native `keyboard-height`
event -- and nothing emits it: `src-tauri/src/lib.rs`'s `on_window_event` is
an empty closure, left unwired until the mobile targets are initialised. Since
`app.css` pins `#root` to `position: fixed; inset: 0`, the layout viewport
does not shrink either, so the composer stayed under the keyboard from the
first tap. The WEB shell had this right all along and nothing compared the
two. It now seeds and tracks from `visualViewport`, reusing the web shell's
`readKeyboardHeight`, and the native event takes over the moment it exists --
one writer at a time, so they cannot race.

Found while testing it: the fake `visualViewport` had `addEventListener` and
no `removeEventListener`, so no adapter that unsubscribes could be exercised
there. The fake now models both.

THE BUNDLE COULD NOT PARSE ON THE FLOOR IT DECLARES. `TARGETS` said
`chrome108` while tauri.conf.json declares `minSdkVersion: 26` -- Android 8.0,
WebView ~Chrome 58 -- and the output carried optional chaining, nullish
coalescing and logical assignment. index.html loads it as a module, so that is
a parse error: the body never evaluates, and `installCrashHandler` is imported
by the same module so it never installs either. A blank white screen with no
error surface. Nothing linked the two numbers; they are derived now, and
tested against each other. Cost: 5.2kB of a 400kB budget.

EVERY SCREEN RENDERED AT ONCE. `.screen { display: flex }` is an author
declaration and beats the UA `[hidden] { display: none }`, so `el.hidden =
true` in mount.ts did nothing. Measured in headless Chrome: after tapping
Settings the chat screen was still on screen at half height with the settings
list stacked underneath, halving again on every navigation. ui/src/screens.ts
was correct throughout; only the stylesheet broke it.

THE COMPOSER'S SAFE AREA WAS COUNTED TWICE, and its gutter lost. `geometryOf`
already returns `max(keyboard, insets.bottom)` -- insets.ts calls the sum "the
single most recognisable 'web page in a box' tell there is" -- and app.css
added the inset back: measured 76px where 42px is correct. Separately main.ts
wrote `padding-inline` inline, beating the stylesheet's gutter, so at 320px
the textarea sat flush at x = 0 while everything else kept 12px.

THE GATE PASSED BUNDLES THAT CANNOT LOAD. `problems` was built from three
checks -- static Node builtins, top-level process.env, size -- so a bare
import esbuild could not RESOLVE was reported shippable. Bundling
praisonai-ts's own webview entry through it reported 80.5kB, 0 problems and
`shippable: true` while silently leaving out openai, ai, four @ai-sdk
packages, chalk, ora, boxen, figlet and cli-table3. That loads on a laptop
with node_modules beside it and dies on a phone.

CI HAD NEVER COMPILED FOR A PHONE. Both `rust shell` legs are host targets, so
`#[cfg_attr(mobile, tauri::mobile_entry_point)]` -- the attribute generating
the Android JNI and iOS start_app symbols -- expanded zero times. A
`cross-check` job now runs `cargo check --lib` for aarch64-linux-android and
aarch64-apple-ios. It needs no NDK and no Xcode project; verified locally at
24s for the android leg. The iOS leg cannot be verified here (Command Line
Tools only, no iOS SDK) and is proved by this PR's own CI.

AND THE SECOND RELEASE WOULD HAVE BEEN REJECTED. tauri.conf.json is hardcoded
at 0.1.0 with nothing to change it -- the same root cause as #4527, but worse
than a wrong label: Tauri derives Android versionCode and iOS CFBundleVersion
from it, and both stores refuse a repeat. tools/set-release-version.mjs takes
the tag and a monotonic build number, following the single-source rule the
desktop package settled on.

Also: Cargo.lock is committed (this is an application; `tauri = "2"` resolved
independently on every machine), @tauri-apps/cli is a devDependency so a
mobile build can be attempted at all, and the generated mobile ACL schemas
land beside the desktop ones.

1326 tests on Node 22.23 and 24.12; cargo test green; cargo check green for
aarch64-linux-android.

Refs #4673, #4640

* fix(mobile): sync package-lock.json with the @tauri-apps/cli devDependency

CI failed in 11-21s across four jobs: adding the dependency to package.json
without regenerating the lockfile makes `npm ci` refuse, and every job starts
with it.

Reproduced locally by running `npm ci` against a copy of the two files, and
verified fixed the same way -- 7 packages added.

The lockfile was updated with --package-lock-only so node_modules was not
touched; this worktree symlinks another one's.

* fix(mobile): sync lock file for @tauri-apps/cli; build floor-parse check in-memory

All 7 CI jobs failed at `npm ci` because package.json added @tauri-apps/cli
but package-lock.json was never regenerated (EUSAGE, lock out of sync).
Regenerated the lock file so `npm ci` is in sync across check, rust shell,
cross-check, and upstream-parity legs.

Separately, tools/bundle-target.test.mjs's "shipped bundle" assertion read a
pre-built dist/app.js, but the `check` job runs `npm test` without a prior
`npm run build`, so it would fail once the lock file was fixed. Build the same
entry in-memory through the same bundle() at the same TARGETS instead -- same
output, no build-order dependency.

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

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 20:15:40 +01:00
MervinPraison 34fc8d9439 docs: auto-update feature parity trackers [skip ci] 2026-09-02 18:23:01 +00:00
praisonai-triage-agent[bot] 2332f32c2e Merge pull request #4682 from MervinPraison/claude/issue-4681-20260902-1717
fix: remove duplicate dead completion-signal regexes in agent.py
2026-09-02 18:22:33 +00:00
praisonai-triage-agent[bot] 6801a60a96 Merge pull request #4680 from MervinPraison/fix/post-windows-gaps
fix(desktop): redact API key from settings replies; stop adopting training runs on a recycled pid
2026-09-02 17:52:39 +00:00
MervinPraison 5394ad4503 docs: auto-update documentation parity trackers [skip ci] 2026-09-02 17:44:14 +00:00
praisonai-triage-agent[bot] a15c7f8fb0 Merge pull request #4679 from MervinPraison/claude/multi-agent-praisonai-platform-dfbgzo
fix(agents): stop auto-compaction latching itself off for the rest of a session
2026-09-02 17:43:32 +00:00
praisonai-triage-agent[bot]andMervinPraison 3081ffb750 fix: remove duplicate dead completion-signal regexes in agent.py (fixes #4681)
Co-authored-by: MervinPraison <[email protected]>
2026-09-02 17:18:54 +00:00
Mervin PraisonandClaude Opus 5 af6fc8402a test(agents): document the low-savings guard tests
CodeRabbit's docstring-coverage pre-merge check reported 50% against an 80%
threshold, scoped to the functions this diff touches.

Each test now carries a docstring saying why it exists rather than restating
its name — what breaks if the assertion stops holding. Three explanatory
comments that were already there moved into the docstrings, which is where
they belonged.

No behaviour change; the 11 guard tests still pass and the full
tests/unit/compaction/ selection is unchanged at 108 passed with the same 3
pre-existing failures.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZfTXTzdudKb4oswNSHpHw
2026-09-02 17:12:12 +00:00
MervinPraisonandClaude Opus 5 77e5dca9b4 fix(desktop): do not adopt a training run on a recycled pid
_reload() adopted any persisted run whose pid was merely alive. src-tauri/src/
adopt.rs already refuses that for the engine -- it compares `start_time` and
names the rejection `PidReused` -- but nothing guarded the training job, so the
same recycled pid the Rust shell rejects was adopted here.

_pid_alive's docstring put the cost at "one adopted-then-reaped run". Nothing
reaps an adopted run: it carries no _proc and no supervisor, so

  * it stays RUNNING forever and start() refuses to launch anything while a
    live run exists -- fine-tuning is wedged shut until someone finds and
    deletes the state file by hand, and
  * stop() takes the `proc is None and run.pid` branch and SIGTERMs the process
    group of whatever unrelated program now holds that pid.

Runs now record the child's start time beside its pid, captured at spawn while
the child is certainly still ours, and adoption requires both to match. A state
file with no fingerprint (written before this change) is refused: ownership
cannot be proven, and the penalty for guessing wrong is signalling a stranger.

Tested against real recycled pids rather than a mock liveness function. All six
mutations are killed, including _persist dropping the field -- which the first
draft of the tests missed, because they hand-wrote the state file and so would
have stayed green while adoption failed closed forever.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 18:02:36 +01:00
MervinPraisonandClaude Opus 5 f6ed3966c9 fix(desktop): stop settings replies returning the API key in cleartext
GET /settings has masked the key since the beginning. POST /settings replied
with save_settings()'s merged dict, and that dict starts from load_settings(),
which reads the real secret back out of the keychain -- so every settings write
answered with the credential in cleartext, including a write that only changed
the theme and never mentioned the key. The engine also sends
`Access-Control-Allow-Origin: *`, so any page open in the user's browser could
read it with a two-line fetch.

Masking one of two exits is not masking. redacted() is applied at both, and is
driven by SECRET_KEYS so a future secret is covered without new code. What is
stored is unchanged; only what leaves the process differs.

The tests assert the effect -- the secret must be absent from the response
bytes -- rather than the shape of the dict, because the dict was correct and
was simply the wrong dict. Reverting either call site turns them red (4/4
mutations killed).

They also set PRAISONAI_KEYCHAIN_SERVICE, not just PRAISONAI_DESKTOP_HOME:
the home var moves the data directory but not the system keyring, so a test
run against the default service overwrites the key the developer is actually
using and the keychain keeps nothing to restore. That is not hypothetical --
it happened while this test was being written.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 17:58:46 +01:00
Mervin PraisonandClaude Opus 5 dadb02f960 fix(agents): stop auto-compaction latching itself off for the rest of a session
The anti-thrashing guard in ContextCompactor could never be released. Once
`_low_savings_streak` reached `max_consecutive_low_savings` (default 2), both
`compact()` and `needs_compaction()` returned early — and the only line that
clears the streak sits *after* a compaction actually runs, which those early
returns skip. The compactor therefore stopped compacting permanently.

That is the opposite of the guard's purpose: it exists to avoid burning a
summarisation call on a conversation that will not shrink, but the effect was
that long-running agents — the ones auto-compaction exists for — silently
stopped compacting and let their context grow without bound.

Measured on a latched compactor, input it would otherwise shrink 2991 → 125
tokens (96%) came back untouched, indefinitely, while an identical fresh
compactor handled it. In a simulated 40-round session, compaction fired 4
times and then never again, with context climbing to 1172 tokens against a
300-token limit.

Fix: the guard can now go stale. `_should_skip_low_savings()` releases it once
the conversation has grown 25% beyond the size it gave up at, on the grounds
that materially more content is new evidence and the earlier verdict no longer
applies; the streak is then reset so the full allowance returns. Both the sync
and async paths, plus `needs_compaction()`, route through that one helper, and
the bookkeeping is likewise consolidated into `_record_savings_outcome()` so
the three sites cannot drift apart again.

Anti-thrashing itself is unchanged: an unchanged or marginally-grown
conversation is still skipped, and `was_skipped_due_to_low_savings` still
reports it.

Worth being precise about the limit: this converts "permanently disabled" into
"periodically re-tested". Context can still sit above the ceiling between
releases — pinning it to the limit would mean compacting on every turn, which
is the thrashing the guard is there to prevent.

Tests: 11 new. Confirmed they catch the defect rather than merely passing
alongside it — reverting the staleness check turns 8 of them red. Coverage
spans the guard holding on an unchanged and a marginally-grown conversation,
releasing on real growth, matching a fresh compactor's output once released,
resetting the streak, agreeing across `needs_compaction()`/`compact()`, the
end-to-end long session, and all three affected strategies.

Verified: `pytest tests/unit -k compact` — 158 passed, 6 failed. Those 6 are
identical to the pre-change baseline on this tree (confirmed by stashing).

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZfTXTzdudKb4oswNSHpHw
2026-09-02 16:37:58 +00:00
Mervin PraisonandClaude Opus 5 b651560f93 fix(bot): bound gateway subprocess calls so a hung command cannot stall the loop (#4677)
* fix(bot): bound gateway subprocess calls so a hung command cannot stall the loop

Four gateway paths shelled out with no timeout. The dispatcher calls them
from inside the asyncio event loop, so a child blocked on stdin — a git
credential prompt, an index.lock, a confirmation prompt — stopped the whole
gateway serving, indefinitely and silently.

Adds praisonai_bot/_guarded_subprocess.run_guarded: a drop-in wrapper that
always bounds the call and, on timeout, returns a CompletedProcess with
returncode 124 and a *diagnosis* in stderr rather than raising. Existing
`returncode == 0` call sites therefore keep working without growing an
exception path.

Two defences, in order:

- stdin is /dev/null and GIT_TERMINAL_PROMPT=0 / GCM_INTERACTIVE=never /
  DEBIAN_FRONTEND=noninteractive are layered under the caller's env, so the
  common cases fail fast instead of blocking. The timeout is a backstop.
- On timeout the partial output is matched against known stall signatures
  and the caller gets an actionable line — "credential prompt", "git lock
  contention", "confirmation prompt, re-run with --yes", "SSH host-key
  prompt" — instead of a bare elapsed time.

Adopted at the four sites that can block the event loop:

- gateway/kanban_dispatcher.py::_run_git — the worst of them, reached from
  dispatch_once and the worktree integration path
- kanban/sqlite_store.py — git rev-parse on the create_task path
- gateway/preflight.py — launchctl on the startup path
- gateway/pairing.py — icacls while hardening the pairing secret

Git keeps a deliberately generous 300s ceiling (PRAISONAI_KANBAN_GIT_TIMEOUT)
because worktree add and merge on a large repository are legitimately slow;
the aim is to bound an indefinite hang, not to fail slow-but-healthy work.
Other commands use 120s (PRAISONAI_SUBPROCESS_TIMEOUT).

Scope is deliberately narrow. The ~66 remaining subprocess.run sites without
timeouts are in daemon/ and CLI paths, where a hang stalls one user command
rather than the gateway; those are worth a separate pass.

Tests: 32 new, covering timeout-without-raising, stdin EOF, each diagnosis
signature, env layering and precedence, unusable env overrides, and that
_run_git routes through the guard.

Verified: tests/unit/kanban + tests/unit/gateway + the new file — 625 passed,
22 failed. Those 22 are identical to the pre-change baseline on this tree
(confirmed by stashing); two further files fail to collect because the
optional `websockets` package is absent here. Neither is related to this
change.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZfTXTzdudKb4oswNSHpHw

* fix(bot): kill the whole process group on timeout, and surface a timed-out repo probe

Addresses two findings from Greptile's review of this PR.

1. Descendants survived the timeout (P1)

subprocess.run(timeout=) kills only the *direct* child, which is not enough
for the commands this guard wraps — git routinely spawns credential helpers
and transport processes, and a stuck credential helper is the motivating
failure. Confirmed against CPython 3.11's subprocess.run:

  except TimeoutExpired as exc:
      process.kill()
      if _mswindows:
          exc.stdout, exc.stderr = process.communicate()   # no timeout
      else:
          process.wait()

On POSIX the call returns but orphans the grandchild. On Windows that
communicate() has no timeout and blocks until every descendant releases the
inherited stdout/stderr pipe — reintroducing the exact indefinite hang this
module exists to prevent, on a platform the gateway supports.

run_guarded now moves off subprocess.run: it starts the command in its own
process group (start_new_session on POSIX, CREATE_NEW_PROCESS_GROUP on
Windows), kills the whole group on timeout (killpg / taskkill /F /T), then
reaps with a *bounded* second wait. If a descendant somehow survives and
still holds the pipe, the captured output is abandoned with a warning rather
than blocking the caller — returning late is the failure mode being fixed.
The same cleanup runs on BaseException, so a KeyboardInterrupt cannot leave
a live child behind either.

`check` is now handled explicitly (matching subprocess.run) since Popen does
not implement it. A timeout still returns rather than raising, so callers
branching on returncode are unaffected.

2. A timed-out repo probe silently disabled worktree isolation

_is_git_repo answers False on timeout, which is correct — a slow path must
never fail the create — but the caller then downgrades the task to the
shared workspace. Losing worktree isolation silently is an operator-visible
concern, so a timeout (returncode 124) is now distinguished from a genuine
"not a repository" and logged at WARNING with the diagnosis. The return
value is deliberately unchanged.

Tests: 5 new (37 total). Two are real regression tests for the group kill —
one asserts run_guarded returns promptly despite a descendant holding the
pipe, the other asserts the grandchild is actually dead by giving it a
marker file it can only write if it outlived the kill. Plus check= semantics.

Also fixes a syntax error introduced in the previous commit: a backslash
inside an f-string expression, which is illegal before Python 3.12 while
this package supports 3.10+.

Verified: tests/unit/kanban + tests/unit/gateway + the new file — 630 passed,
22 failed, matching the pre-change baseline on this tree exactly.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZfTXTzdudKb4oswNSHpHw

* fix(bot): reject non-finite timeout overrides that would restore an unbounded wait

From CodeRabbit's review. `float("inf")` parses cleanly and satisfies a bare
`value > 0` check, so PRAISONAI_SUBPROCESS_TIMEOUT=inf (or Infinity, +inf,
1e400) was accepted and handed back as the timeout — silently reinstating the
exact indefinite wait this module exists to remove. Verified:

  >>> float("inf") > 0
  True

Both resolvers now require math.isfinite() as well as a positive value:
_default_timeout in _guarded_subprocess and _git_timeout in the kanban
dispatcher. `nan` already failed the comparison, but is covered explicitly so
the intent is not left to a subtlety of IEEE comparison.

Tests: 11 new (48 total), parametrised over inf/Infinity/+inf/1e400/nan for
both resolvers. Confirmed they catch the bug rather than merely passing —
reverting the isfinite guard turns 4 of them red.

One of the new tests was initially written as run_guarded(..., timeout=0.3)
with the env var set, which would have passed even with the bug present
because an explicit argument bypasses the env path entirely. Rewritten to
assert on the resolver directly.

Also adds class-level docstrings to the test suite, stating the invariant
each group pins. Per-method docstrings restating self-documenting names like
test_hanging_command_returns_instead_of_blocking were deliberately not added.

CodeRabbit's other finding (process-group termination) was already fixed in
40d37c88a; its review ran against the earlier commit c9964dbe0.

Verified: tests/unit/kanban + tests/unit/gateway + the new file — 641 passed,
22 failed, matching the pre-change baseline on this tree exactly.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZfTXTzdudKb4oswNSHpHw

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-02 17:24:25 +01:00
praisonai-triage-agent[bot] eef24398ea Merge pull request #4675 from MervinPraison/claude/issue-4635-20260902-1241
fix(mobile): wire the nine unreachable UI modules into main.ts
2026-09-02 14:46:06 +00:00
praisonai-triage-agent[bot]andMervin Praison 4a46a31fca fix(mobile): reopened history reconciles as a prefix; chat-list load failures stay local
Two P1 defects in the #4635 wiring:

- Reopening a chat appended raw <p> rows straight to the transcript while
  render/nodes stayed empty, so the next token reconciled the new turn against
  an empty render and inserted it ABOVE the history, and New chat could not
  remove rows it never keyed. History is now a reconciled PREFIX built with
  stable `history:N` ids, painted through the same reconcile/applyOps path and
  dropped on New chat.
- The chat-list load was a floating `void (async ...)()` with no rejection
  handler; a StoragePort rejection reached the global crash handler and
  replaced the whole app with the fatal screen. It now catches locally and
  paints an in-section alert.

Adds two end-to-end tests: a message sent after reopening lands below the
history, and a storage failure while the chat list loads stays local.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 14:05:48 +00:00
praisonai-triage-agent[bot] 01a7baf7a6 Merge pull request #4676 from MervinPraison/claude/issue-4635-20260902-1303
fix(mobile): wire the nine UI modules into main.ts (fixes #4635)
2026-09-02 14:01:42 +00:00
praisonai-triage-agent[bot] 123d61c529 Merge pull request #4674 from MervinPraison/claude/issue-4673-20260902-1249
fix(mobile): default to in-process engine on device
2026-09-02 13:50:21 +00:00
praisonai-triage-agent[bot]andMervin Praison c0b9803d8e fix(mobile): keep reopened history in reconcile + move route focus
Two P1 defects Greptile flagged on #4676:

- Reopening a chat appended untracked <p> nodes and reset render to
  empty, so the next turn reconciled from nothing and inserted rows
  ABOVE the history, which itself could never be reconciled again.
  History is now built as real Rows and prepended to every reconcile,
  so a follow-up turn lands below it and survives the diff.
- showRoute computed the focus target but never applied it and always
  treated navigation as a push. Focus now moves to the new screen's
  heading, push/pop is derived from stack depth, and Back restores
  focus to the control that opened the screen.

Adds fake-dom focus()/activeElement/isConnected and two mount tests
(send-after-reopen ordering, route-change focus + Back restore).

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 13:27:05 +00:00
praisonai-triage-agent[bot]andMervin Praison 090c06f985 fix(mobile): wire the nine UI modules into main.ts so they are reachable (fixes #4635)
The nine unreachable UI subsystems (composer, settings, i18n locale+bundle,
transcript scroll, a11y focus+names, layout insets, chat list) are now imported
directly by app/src/main.ts rather than only by their own tests. All five
acceptance criteria are met: Settings paints, a conversation can be reopened,
the transcript follows the stream with jump-to-latest, the locale is detected
(making the #4607 RTL fix reachable), and the composer is backed by composer.ts.

Five new end-to-end tests drive the real mount() through the fake DOM,
including "tapping Settings paints the settings screen".

npm run check: typecheck clean, boundaries 142 files no violations, 1300 tests pass.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 13:10:05 +00:00
praisonai-triage-agent[bot]andMervin Praison 34a2e3ee13 fix(mobile): keep remote-http as first-launch default until in-process ships
The platform-based default flipped native (tauri) to the in-process
praisonai-ts engine, but that engine's module is absent from the shipping
webview (build-webview.mjs bundles only main.ts; the engine reaches
praisonai-ts via a runtime import outside dist, #4437). On a real device its
first turn rejects "unavailable in this build" with no recovery -- worse than
the "not answering" warning it replaced, and the brick registry.ts keeps the
engine out of the picker to avoid. Platform["kind"] also cannot tell desktop
Tauri (cargo tauri dev) from a phone, so the ternary stranded desktop/dev too.

Default stays remote-http on every platform for now; the exported
defaultEngineIdFor seam is kept for when #4437 makes the engine shippable.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 13:04:00 +00:00
praisonai-triage-agent[bot]andMervinPraison 1a45f462a9 fix(mobile): default to in-process engine on device (fixes #4673)
A fresh install on a phone defaulted `engineId` to `remote-http` at
`http://127.0.0.1:8765`, which is nothing a device can reach and is a
cleartext localhost address iOS ATS and Android refuse -- so first launch
opened to a "not answering" warning with no way to fix it (Settings is
unreachable, #4635). Default to the in-process `praisonai-ts` engine on
native and keep `remote-http` for desktop/dev; the persisted setting still
wins over either, so this only decides the first launch.

Co-authored-by: MervinPraison <[email protected]>
2026-09-02 12:54:46 +00:00
Mervin Praison 2451fa8f81 fix(mobile): an unreachable engine must not stop the app from starting (#4672)
main was RED: `npm run check` exited 1 with 17 failures, all in
app/src/main.test.ts.

#4647 made the health probe a hard boot gate. It added tests for boot.ts,
registry.ts and engine.ts, and never ran main.test.ts -- which mounts the
whole app against a fake HTTP with no /health route. Driving mount() directly:

    no /health handler   ->  "PraisonAI could not start: 404"
    /health says ok      ->  "PraisonAI  New chat  Settings  Send"

That is not only a test problem. On a phone with no desktop engine to reach,
the app now would not open at all -- and the user could not correct the
address, because the address is changed in Settings and Settings is inside
the app that will not start. #4638 asked for a named failure WITH a bounded
retry and warned explicitly that boot must not hang on a dead engine;
`retryable` was computed, carried all the way to boot.ts:167, and then read by
nothing.

The line is drawn where readiness.ts already draws it:

- PERMANENT (`retryable: false` -- a version mismatch) still refuses to boot.
  Waiting will not fix it, so booting into it only defers the same failure to
  the user's first message. The engine is disposed, as before.
- RETRYABLE (transport, unhealthy, malformed) hands the engine over with its
  unreadiness attached. The app starts; main.ts renders a warning notice into
  the transcript and announces it assertively, because the user is about to
  type into something that cannot reply yet. The engine is NOT disposed -- it
  is the one the app will use when the remote comes up.

Everything #4647 was for is kept: the probe still runs at selection, a 200
with {"ok": false} is still not readiness, and the reason still has a name.
Only the verdict on a recoverable failure changed.

Three tests from #4647 asserted the hard gate and are rewritten to the new
contract rather than deleted, each keeping its original intent and gaining the
pair that makes the boundary visible: a permanent unreadiness still refuses, a
retryable one boots and reports, a permanently refused engine is disposed and
a retained one is not until the app is.

1295 tests on Node 22.23 and 24.12. Seven mutations -- including reinstating
the hard gate -- all die.

Refs #4638
2026-09-02 13:23:36 +01:00
praisonai-triage-agent[bot]andMervin Praison f937827235 fix: re-apply engine packages when the requirement drifts (#4668) (#4670)
* fix: re-apply engine packages when the requirement drifts (fixes #4668)

The desktop venv is provisioned once, on first run, and never revisited, so
a library fix shipped in praisonaiagents never reaches existing installs and
raising ENGINE_PACKAGES changes only what new installs get.

Record what was installed in a .praisonai-packages stamp beside the venv, and
on each launch compare it against the current ENGINE_PACKAGES; when they
differ, re-run the single install step (uv is near-instant when nothing
changed) and rewrite the stamp. A missing stamp is treated as drift so
pre-stamp installs get the current set once. The stamp lives inside the venv
so deleting the venv also clears the record. Failure still starts the engine,
letting the UI report the version actually installed.

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

* fix: reconcile only the venv we own, and never adopt a stale engine (#4668)

Address the three reviewer findings on package reconciliation:

- Wrong interpreter (Greptile P1): reconcile only when the resolved
  interpreter is data/venv's own python; a PRAISONAI_PYTHON override or
  checkout venv is left untouched instead of installing into (or failing
  against) an unrelated user-data venv.
- Adoption bypass (Greptile P1): when packages were actually installed,
  the survivor is running the old code, so the health probe is forced
  false -- reclaim kills it and clears the lock (PID-reuse guard intact)
  and a fresh engine that loaded the new packages is started.
- Stuck "updating…" label (Greptile P2): the label is now set and
  cleared inside reconcile around the install only, so a no-drift launch,
  a non-owned interpreter, or a later failure never strands the tray.

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

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-09-02 13:05:46 +01:00
praisonai-triage-agent[bot]andMervin Praison 7451a62690 feat: add headless CLI to drive the desktop engine (#4671)
* feat: add headless CLI to drive the desktop engine (fixes #4669)

Adds src/praisonai-desktop/cli.py, a stdlib-only driver that speaks to the
same loopback routes the Tauri app does, so desktop bugs can be reproduced
from a terminal, over SSH, or in CI instead of only through the GUI.

- engine start  : run the engine, print the announced port
- engine health : versions, pid, data dir, port (found via the lockfile)
- chat          : one turn streamed to stdout; a tools-ran-but-no-text turn
                  (the 1.7.1 failure) is named rather than shown as blank
- doctor        : installed vs required praisonaiagents, reading the floor
                  from src-tauri/src/provision.rs so it cannot drift

No new engine code and no dependencies: it reuses engine/server.py for the
data dir, lockfile format and version lookup. Adds engine/test_cli.py.

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

* fix: isolate CLI chat sessions, answer approval gate, verify engine identity

Addresses reviewer feedback on the headless desktop CLI:
- Send `session` (=chat_id) so each --chat-id gets its own cached agent
  rather than sharing the engine's default session state.
- Answer approval_request over /approve so a tool-enabled turn does not
  block on the gate for the full timeout; --approve to allow, else deny.
- Verify /health protocol version in _live_port so a recycled port answered
  by another loopback service is not adopted as the engine.

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

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-09-02 13:05:35 +01:00
praisonai-triage-agent[bot]andMervin Praison d6c7d68e5e test: verify ExecutionConfig.max_rpm wires a live RateLimiter (fixes #3701) (#4666)
The core wiring (agent.py auto-builds RateLimiter from max_rpm, YAML
parser promotes max_rpm to a live limiter) is already present on main.
This closes the remaining verification gaps the issue flagged:

- Strengthen the YAML test so it asserts a live RateLimiter with the
  correct rpm, not just the dead `_yaml_max_rpm` metadata attribute.
- Add a behavioural regression test proving the auto-wired limiter
  actually throttles (injectable clock: 2nd acquire() waits ~60s under
  max_rpm=1), covering the issue's key behavioural acceptance criterion.

No production code change; existing fix confirmed via reproduction.

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 12:38:23 +01:00
praisonai-triage-agent[bot]andMervin Praison d4197e6303 feat: add EvalPort adapter for praisonaiagents.eval (#4667)
* feat: add EvalPort adapter for praisonaiagents.eval (fixes #4275)

Dependency-free framework-neutral interop for the eval package. Adds
to_evalport()/from_evalport() (EvalPackage <-> EvalPort Suite) and
report_to_evalport() (EvalReport -> EvalPort ResultSet), mirroring the
existing harness_eval.py plain-dict pattern. No new dependency on
evalport-sdk; callers validate the emitted dicts with openeval themselves.

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

* fix: prevent EvalPort timeout/metadata round-trip corruption

Emit native timeout_seconds as a dedicated top-level EvalPort field
instead of stuffing it into metadata via setdefault, which silently
clobbered a user-supplied metadata['timeout_seconds'] key and lost the
native value on round trips. Import prefers the top-level field and
falls back to metadata for external suites, without mutating metadata.

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 12:38:08 +01:00
praisonai-triage-agent[bot] fcc6acab83 Release v4.7.5 2026-09-02 10:59:53 +00:00
MervinPraison 97efe3e1c4 docs: auto-update documentation parity trackers [skip ci] 2026-09-02 10:18:00 +00:00
praisonai-triage-agent[bot]andMervinPraison d74ed846d8 fix: consume identity-canonicalization seam in gateway session key (fixes #4661) (#4665)
The gateway keyed each session on the raw platform user id, so a
platform-side id change for the same human (WhatsApp JID->LID migration,
a number<->UUID alias flip) silently forked the conversation, orphaning
history, memory and run-state.

Core already shipped IdentityCanonicalizerProtocol (canonicalize(platform,
raw_user_id) -> str) but nothing consumed it. Reuse that existing seam
instead of adding a new API:

- BasePlatformAdapter.canonicalize() default identity, so an adapter *is*
  an IdentityCanonicalizerProtocol.
- Add identity_canonicalizer to the GatewayRuntimeSeams carrier.
- BotSessionManager applies the canonicalizer in _storage_key() before the
  link/pairing resolver; None (default) is the identity function so
  behaviour is byte-for-byte unchanged. Fail-open: any error/empty result
  falls back to the raw id so a buggy canonicalizer never breaks keying.

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-09-02 11:17:15 +01:00
praisonai-triage-agent[bot]andMervinPraison b80be81549 fix: add live session handoff to gateway (fixes #4660) (#4664)
Add BotOS.handoff() to re-home an in-progress conversation onto a fresh
thread/DM on another platform ("start on one device, continue on another").
It orchestrates existing gateway primitives — DeliveryRouter.create_thread
to open the destination thread, and new BotSessionManager.export_history /
seed_history to move the live transcript onto the destination session — plus
an optional seed message and origin breadcrumb. Returns a lightweight
ThreadRef carrying the destination session key and a graceful non-ok status
for unsupported/unreachable targets (never raises).

Wrapper-layer only: no Agent params, no new heavy module, no core change.

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-09-02 11:16:57 +01:00
praisonai-triage-agent[bot]andMervin Praison aeeae70633 fix: add natural-language schedule expressions to parser (#4663)
* fix: add natural-language schedule expressions to parser (fixes #4662)

Extend the core parse_schedule() with a small NL->cron pass so users can
write "weekdays at 9am", "every day at 9am", "every monday 9am" and "at 9am"
instead of raw cron. Recurring day-of-week/clock forms map to cron; a bare
clock time maps to a one-shot next occurrence. All existing forms are
unchanged and no new hard dependency is added.

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

* fix(scheduler): honor timezone for one-shot clock times and support 'daily at <time>'

- _clock_to_next_at now resolves the clock time in the schedule's timezone
  so "at 9am" with tz="America/New_York" fires at 09:00 local, not 09:00 UTC.
- Add "daily" to the natural-language filler set so "daily at 9am" parses to
  the daily cron "0 9 * * *" instead of raising ValueError.
- Add regression tests for the daily form and the timezone-aware one-shot
  (both parse output and is_due evaluation).

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

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-09-02 11:16:36 +01:00
praisonai-triage-agent[bot]andMervin Praison 0cb3147c7f fix: report real OpenAI usage tokens from gateway /v1 endpoints (#4658)
* fix: report real OpenAI usage tokens from gateway /v1 endpoints (fixes #4656)

Map the agent's last-turn token metrics (last_token_metrics) into the
OpenAI usage block for /v1/chat/completions and /v1/responses instead of
hardcoded zeros. Streaming honors stream_options.include_usage by emitting
a final usage-only chunk. Buffered streaming and OpenAI tools passthrough
are intentionally left out to avoid a gateway hot-path rewrite / surface
expansion per AGENTS.md.

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

* fix: snapshot OpenAI usage atomically to prevent cross-request misattribution

Reviewers (Greptile/Qodo P1) flagged that _usage_for(agent) read the shared,
mutable llm.last_token_metrics after dispatch. With one Agent serving
overlapping sessions on the single gateway event loop, a concurrent turn could
overwrite that reference before the response was built — most acutely in the
streamed usage path where the SSE generator suspends between frames — causing
token counts (and billing) to be attributed to the wrong request.

_dispatch now returns (reply, usage), snapshotting the per-turn metrics into a
plain-int dict immediately after the turn completes with no intervening await.
All call sites (chat, streamed chat, responses) consume the bound snapshot;
MCP tools/call ignores it. Adds a unit test binding the snapshot to its turn and
a streaming regression test proving the usage frame survives a concurrent
overwrite (fails on the pre-fix inline read).

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

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

* fix: snapshot OpenAI usage inside the turn's own context to close sync-thread race

Greptile/Qodo P1 (line 134): the prior "no intervening await" snapshot only
orders event-loop coroutines. The sync-only agent fallback runs agent.chat in
a worker thread, so a concurrent turn on another thread could overwrite the
shared llm.last_token_metrics between the turn completing and the handler
reading it — misattributing token usage/billing.

- _dispatch_agent_turn now accepts on_complete(agent), invoked synchronously in
  the SAME context that produced the result (inside the worker thread for sync
  agents; on the loop tick with no intervening await for async agents), before
  the context is handed back — so the snapshot is bound to this turn.
- _dispatch passes an on_complete capture callback instead of reading usage
  after the await; keeps the (reply, usage) contract.
- Backward compatible: on_complete defaults to None; existing call sites unchanged.
- Regression test asserts the sync worker-thread snapshot ordering against the
  real WebSocketGateway._dispatch_agent_turn.

21 -> 22 gateway endpoint tests pass; agents gateway_server dispatch tests green.

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-09-02 11:16:22 +01:00
praisonai-triage-agent[bot]andMervin Praison 39466aee5b fix: bind gateway approvals to turn liveness (#4600)
* fix: bind gateway approvals to turn liveness (fixes #4598)

Bind a pending gateway approval to the run generation / session of the
turn that requested it, so a superseded or stopped turn cannot leave a
turn parked indefinitely on a blocking approval wait, and a resolution
that arrives after the turn is gone cannot fire a stale tool or deliver
a reply for an abandoned turn.

- core: ApprovalRequest gains an optional no-op ``liveness`` predicate.
- gateway ExecApprovalManager: register() accepts optional
  session_id/run_generation; cancel_for_generation() fail-closes pending
  futures and marks the generation superseded; resolve() drops a stale
  resolution (fail-closed). Unbound requests behave exactly as today.
- gateway ApprovalBackend plumbs session_id/run_generation through.
- SessionRunControl gains an optional on_supersede callback fired on
  /stop and interrupt so the gateway can cancel the old turn's approvals.

Backward compatible: no new user config; the binding is a no-op when a
request carries no session/generation.

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

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

* fix: wire approval turn-liveness into production + harden register (#4598)

Address reviewer findings on PR #4600 (Greptile, CodeRabbit):

- Wire the supersede callback in production: telegram bots now build
  SessionRunControl with on_supersede=make_approval_supersede_callback(),
  bridging /stop and interrupt to ExecApprovalManager.cancel_for_generation
  so an approval-parked turn actually unblocks (was previously dead code).
- Fail-close register(): if a turn's generation was already superseded
  (TOCTOU with /stop), deny the future immediately instead of parking it.
- Validate run_generation: coerce/drop a non-integer binding to unbound so
  a bad value can never silently make an approval un-cancellable.
- Tests: exercise the production wiring end-to-end (stop -> cancel), plus
  register-after-supersede and non-integer generation; resolve RUF059.

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

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 11:15:59 +01:00
praisonai-triage-agent[bot]andMervin Praison 69960c6f31 fix(mobile): probe engine health at boot, not mid-turn (#4647)
* fix(mobile): probe engine health at boot so an unhealthy engine is refused with a name, not mid-turn (fixes #4638)

probeHealth and readiness.ts had no non-test caller: nothing ran the
readiness check the module exists for, so a broken or still-starting
engine surfaced as a transport error part-way through the user's first
answer instead of a named failure at boot.

Wire the probe in at engine selection, respecting the seam:
- add an optional `probe?: () => Promise<Readiness>` to EngineChoice,
  run in selectEngine after the protocol/id checks; a non-ready verdict
  is a named selection failure carrying reason/detail/retryable, and the
  refused engine is disposed like a protocol mismatch already is
- the remote-http choice in registry.ts supplies probeHealth with its
  own http/baseUrl (boot cannot and must not know them); the in-process
  engine has no remote and supplies no probe
- boot forwards retryable so a caller can poll a still-starting engine

Boot-only for now; the resume/connection-manager path is separate.

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

* fix(mobile): bound the boot health probe with a deadline and pin its registry wiring

The probe runs before the app mounts, so a stalled /health -- accepted but
never answered, or a body that never closes -- would hang selectEngine,
createApp and the whole mount forever (the AbortController was created but
never aborted). probeHealth now aborts off a bounded deadline and classifies
the timeout as retryable transport, and releases the reader lock on any exit.

Also adds registry-level tests driving the REAL probe enginesFor builds, so
dropping or misconfiguring its `probe` wiring now fails the suite.

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-09-02 10:49:36 +01:00
praisonai-triage-agent[bot]andMervin Praison 62cf2709ec fix(mobile): offer the in-process praisonai-ts engine from the composition root (#4649)
* fix(mobile): offer the in-process praisonai-ts engine from the composition root (fixes #4637)

main.ts never supplied `createInProcess`, so `enginesFor` only ever offered
ENGINE_REMOTE_HTTP and the whole of engines/src/praisonai-ts/ was unreachable
from the application -- the reason praisonai-mobile exists as a package.

main.ts now supplies the factory via `appEngines`, an extracted+exported
function so a test asserts the engine is offered rather than that a literal
appears in mount(). The praisonai `Agent` is loaded through a runtime-computed
dynamic import so neither tsc nor esbuild pulls the upstream graph in: its bare
crypto/events imports are import-time fatal (#4437) and its sources do not
typecheck under this config -- exactly the invariant agent-api.ts documents.
The remote engine stays the default and the sole picker choice, so nothing
bricks; the bundle stays 48.9kB with 0 fatal builtins.

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

* fix(mobile): make the in-process engine's lazy import fail recoverably

The in-process praisonai-ts factory resolves its agent module through a
runtime-computed dynamic import. Where praisonai-ts is not on disk (the
shipping webview bundles only dist/, #4437) that import rejects with an
opaque "cannot find module" from deep inside the engine's run loop.

Wrap the import and re-throw a plain Error, which engine.ts's existing
catch turns into a single recoverable `error` event -- the named,
on-screen failure engines.ts argues for, not a crash. This does NOT add
the engine to the shipping picker (registry.ts still keeps it out of
engineId.choices); it makes the one path that offers the factory honest.

Adds a composition-root test that constructs the in-process engine and
drives run() to prove create() never throws and the import failure
arrives as ["start", "error"], not an unhandled rejection.

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

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-09-02 10:49:19 +01:00
praisonai-triage-agent[bot]andMervin Praison 900a017065 fix: honour gateway.turn_lock.backend='redis' with a distributed turn lock (#4657)
* fix: honour gateway.turn_lock.backend='redis' with distributed lock (fixes #4655)

Implements the wrapper-side RedisTurnLock (LockMap-compatible) plus a
build_turn_lock factory that selects the backend from TurnLockConfig, and
wires BotOS to honour the config where the in-process LockMap was built
unconditionally. Redis outage fails open (local serialisation + a
DegradedCapabilityRegistry entry) rather than silently downgrading.

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

* fix: wire redis turn lock into gateway + lease renewal, safe teardown (#4655)

- Gateway now honours turn_lock.backend='redis': _stamp_turn_lock_map builds
  the backend via build_turn_lock from config (push.redis) and wires a
  distributed lock unconditionally (even a single channel/no resolver), so the
  config is no longer inert. Local backend keeps today's no-op behaviour.
- RedisTurnLock: renew the lease at ~ttl/3 with an owner-checked compare-and-
  PEXPIRE so a turn longer than ttl never loses cross-replica exclusivity.
- _RedisLease.__aenter__ now releases the local lock on any failure/cancellation
  (BaseException), preventing a permanently-wedged session on BotOS.stop.
- Removed the dead deadline branch; the poll loop is bounded by the holder's TTL.
- BotOS forwards degraded_registry to build_turn_lock so outages surface in
  health()/doctor.
- Tests: FakeRedis models px TTL expiry + PEXPIRE; added coverage for renewal,
  expired-lease reclaim, owner-checked release, in-process serialisation during
  a Redis outage, and gateway redis-backend wiring without a resolver.

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

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-09-02 10:48:51 +01:00
f60f381887 fix(conditions): evaluate_condition falls through to truthy instead of the documented fail-safe False (#4617)
* fix(conditions): fail safe on an unresolved comparison operator instead of falling through to truthy

Check the original condition template for a leftover comparison
operator, not the post-substitution text. A placeholder whose
substituted value happens to contain '>', '<', '==', etc. was being
misread as an unresolved comparison and forced to False, even though
the condition template itself had no operator (caught by coderabbitai
and greptile review on this PR).

Adds a regression test for that case.

* fix(conditions): skip comparison parsers when template has no operator

A plain flag like "{{flag}}" whose substituted value happens to look like
a comparison (e.g. "5 < 3" or "a == b") was mis-parsed by the numeric and
string-equality parsers before the template-based operator guard could run,
returning the comparison result instead of the documented truthy-string
behavior. This could route AgentFlow/Task branches incorrectly.

Gate all comparison parsing (and the fail-safe fallback) on whether the
original condition template contains a comparison operator. Plain flags
now always fall through to the truthy check regardless of their value.

Adds regression tests for placeholder values that parse as numeric ("5 < 3")
and equality ("a == b") comparisons.

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 10:48:32 +01:00
praisonai-triage-agent[bot]andMervin Praison 6cf9697090 fix: async-tool coroutine leak, async/sync tool-loop parity, hook-registry races (#4634)
* fix: async-tool coroutine leak, async/sync tool-loop parity, hook-registry races (fixes #4633)

Gap 1: the sync OpenAI-native tool path (_execute_tool_impl) never awaited
async tool functions, handing the model a bare coroutine object instead of
the real result. Await any coroutine returned by BaseTool.run / LangChain
.run / CrewAI ._run / plain functions via the existing
_run_async_in_sync_context bridge.

Gap 2a: get_response_async now accepts and honours parallel_tool_calls,
dispatching independent tool calls concurrently via asyncio.gather (mirroring
the sync create_tool_call_executor(parallel=True)) while preserving call
order for message building. Previously the parameter was silently absorbed by
**kwargs.

Gap 2b: the async tool-resolution path (execute_tool_async) now shares MCP
resolution and hallucinated-name self-repair with the sync path via new
_resolve_mcp_tool_result / _self_repair_tool_name helpers, and returns the
same corrective, model-readable error (available tools + closest match) on a
miss instead of a bare "not found".

Gap 3b: HookRegistry now guards register/unregister/clear/enable/disable and
snapshots the hook list in get_hooks under an RLock, eliminating the
"list changed size during iteration" race on the process-wide default
registry.

Gap 3a (per-agent display-callback registry) intentionally deferred: a proper
fix requires threading agent identity through the public
register_display_callback API and every display callsite, expanding surface
area against the lightweight-core mandate; the existing "first mode wins"
guard already mitigates the primary clobber.

* fix: async MCP timeout normalization parity + drain parallel tool siblings

- Async path (_execute_tool_async_impl) now applies the shared
  _normalize_mcp_result so an MCP timeout classifies as a retryable
  {"error":..., "timeout": True} dict instead of being handed to the
  model as a bare "Error: ..." string (Greptile P1, valid parity gap).
- Extracted _normalize_mcp_result to a shared staticmethod so sync and
  async paths cannot diverge again.
- Parallel async tool dispatch uses asyncio.gather(return_exceptions=True)
  and re-raises the first error only after all siblings settle, so a
  single failing tool no longer cancels/orphans in-flight sibling
  coroutines while preserving fail-fast propagation.

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 10:47:24 +01:00
praisonai-triage-agent[bot]andMervin Praison 5d7f5aef6b docs: clarify memory.HooksManager is standalone/opt-in (finding 3 of #4281) (#4626)
* docs: clarify memory.HooksManager is standalone/opt-in, not auto-wired (fixes #4281)

Finding 3 of #4281: memory/hooks.py HooksManager.execute() is never invoked
by the agent/tool pipeline, but its docstrings implied it acts as an active
pre/post operation control around agent file/command/prompt operations.

Correct the module, class, and memory.__init__ docstrings to state it is a
standalone, opt-in utility (caller must invoke .execute()) and point users to
the live praisonaiagents.hooks package (HookEvent.BEFORE_TOOL/BEFORE_LLM via
HookRunner) for hooks that fire automatically around agent tool/LLM calls.

Findings 1 (async guardrail event-loop blocking) and 2 (async tool
circuit-breaker parity) were already fixed by merged PR #4469.

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

* docs: correct HookRegistry vs HookRunner guidance in memory/hooks.py (finding 3 of #4281)

Greptile flagged that the previous docstring implied a HookRunner is passed
to Agent(hooks=...). Agent actually accepts a HookRegistry (or list/HooksConfig)
and constructs the HookRunner internally. Clarify to register handlers on a
HookRegistry (via @registry.on(HookEvent.BEFORE_TOOL)) and pass that registry.

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 10:47:07 +01:00
praisonai-triage-agent[bot]andMervin Praison 694750951f fix: correct Arize observability npm package metadata (fixes #4620) (#4645)
Replace nonexistent 'arize-phoenix' (PyPI name, 404 on npm) with the
'@arizeai/openinference-core' package in OBSERVABILITY_TOOLS metadata and
the createArizeAdapter fallback warning.

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 10:42:32 +01:00
praisonai-triage-agent[bot]andMervin Praison d43eedb237 fix(mobile): remove five settings no code reads (#4646)
* fix(mobile): remove five settings no code reads (fixes #4636)

model, temperature, showReasoning, showDiagnostics and apiKey were declared
in SETTING_DEFS but read by nothing, so moving any of them did nothing --
showDiagnostics even claimed to hide dropped events that render
unconditionally, and apiKey could never be written (setSecret had no caller)
nor sent (enginesFor never passed a token). Removed rather than half-wired,
keeping only engineId and baseUrl, the two the shipping app reads. A new
CONSUMED_SETTING_KEYS list is pinned by registry.test.ts so declaring a
setting without wiring a consumer fails. The store's secret and validation
machinery stays (contract-tested) for the day a settings screen exists.

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

* test(mobile): derive the consumed-setting guard from real source reads

Qodo flagged that the registry guard compared SETTING_DEFS against a
hand-maintained twin list (CONSUMED_SETTING_KEYS): adding the same inert
key to both arrays passed, so it did not actually forbid an unread
setting.

The guard now derives "consumed" from the shipping source. A declared
key must appear in a real settings read -- settings.get/isSet("key") or
the stringSetting/chosenStringOr helpers -- in boot.ts or registry.ts,
following the readFileSync/import.meta.url cross-file check idiom already
used in protocol/src/version.test.ts. A key added to both declarations
without a live reader now fails (verified by injecting a phantom key).

typecheck clean, app suite 129 pass, boundaries clean.

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-09-02 10:41:02 +01:00
MervinPraison 3d137c5300 docs: auto-update documentation parity trackers [skip ci] 2026-09-02 09:40:36 +00:00
eb6f8f4a91 fix(learn): capture_improvement takes area=, not source= — auto-extracted improvements are never stored (#4619)
* fix(learn): capture_improvement takes area=, not source=

* fix(learn): align async aprocess_conversation with improvements capture

The sync process_conversation fix (source= -> area=) landed, but the async
sibling aprocess_conversation never extracted or stored improvements at all —
no prompt line, no capture branch, no improvements key in its result. Async
auto-extraction silently dropped every improvement.

Mirror the sync path: add the IMPROVEMENTS prompt line, the capture_improvement
branch (area="auto_extraction"), and improvements keys in stored/return/fallback
dicts. Update the stale empty-messages test to expect the improvements key.

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 10:40:02 +01:00
praisonai-triage-agent[bot] ec6baddb3c Merge pull request #4653 from MervinPraison/claude/issue-4652-20260902-0851
feat: add atomic Move/Rename to apply_patch
2026-09-02 09:39:48 +00:00
MervinPraison 67aaa17771 docs: auto-update documentation parity trackers [skip ci] 2026-09-02 09:24:06 +00:00
praisonai-triage-agent[bot] da93fa822d Merge pull request #4651 from MervinPraison/claude/issue-4650-20260902-0821
fix: single-source YAML fields, data-driven provider inference, drop third integration registry
2026-09-02 09:23:32 +00:00
praisonai-triage-agent[bot] 2f88f73d53 Merge pull request #4623 from MervinPraison/claude/issue-4441-20260902-0655
fix: show setup UI when engine not ready and Train view is restored (#4441)
2026-09-02 09:18:53 +00:00
praisonai-triage-agent[bot] 1de82382c8 Merge pull request #4648 from MervinPraison/claude/issue-4640-20260902-0748
fix(mobile): wire ui/src/index.ts barrel into the real import graph
2026-09-02 09:17:12 +00:00
praisonai-triage-agent[bot] 7cf9338ab0 Merge pull request #4643 from MervinPraison/claude/issue-4635-20260902-0746
fix(mobile): delete unreachable ui/src/index.ts barrel
2026-09-02 09:11:50 +00:00
praisonai-triage-agent[bot] a6fc9e226c Merge pull request #4644 from MervinPraison/claude/issue-4621-20260902-0749
fix: pass stable AgentMail Idempotency-Key across outbound retries
2026-09-02 09:10:35 +00:00
praisonai-triage-agent[bot]andMervin Praison 0d4ad97ef6 fix(tools): harden apply_patch Move against clobbering, conflicts, stale rollback
Addresses three P1 review findings on the new Move/Rename operation:
- Staging paths (src/dst/backup) now collision-check so a pre-existing
  `.praison_patch_tmp` sibling is never truncated or deleted.
- Transaction-wide output-path tracking rejects two operations producing
  the same destination (move->move, move->add, update collisions) instead
  of silently clobbering; a move that frees its source lets a later op reuse
  the path.
- Move rollback restores the source file's recorded read-hash so the
  staleness guard stays armed after an aborted patch.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 09:03:14 +00:00
MervinPraison df84018fbc docs: auto-update feature parity trackers [skip ci] 2026-09-02 09:02:58 +00:00
praisonai-triage-agent[bot] 7ba37102c6 Merge pull request #4642 from MervinPraison/claude/issue-4620-20260902-0749
fix: correct Arize observability npm package metadata
2026-09-02 09:02:29 +00:00
praisonai-triage-agent[bot]andMervinPraison 5b20ad58a3 feat: add atomic Move/Rename operation to apply_patch (fixes #4652)
Add a fourth '*** Move File: <old> -> <new>' operation to the structured apply_patch tool. Renames are now lossless and atomic instead of forcing a Delete+Add pair that restates the whole file body.

- Parse the Move header into {op:move, src, dst, hunks} (hunks optional so a file can be renamed AND edited in one operation).

- Validate source exists, destination is free, both boundary-checked; apply the same last-read staleness guard used by Update.

- Lock BOTH endpoints across validate+commit so the rename is race-safe.

- Commit inside the existing all-or-nothing transaction with full rollback (write destination, rename source aside; restore on any later failure).

- Preserve BOM/CRLF exactly like Update; surface 'Renamed X -> Y' in output.

Backward compatible: patches without a Move header behave exactly as before.

Co-authored-by: MervinPraison <[email protected]>
2026-09-02 08:55:14 +00:00
praisonai-triage-agent[bot]andMervin Praison a994e97ce9 fix: restore Anthropic cloud-form provider inference; stop task fields leaking into agent validation
- model_providers: match "claude" as a substring so cloud-vendored forms
  (us.anthropic.claude-3-5-sonnet, anthropic.claude-...) resolve to
  anthropic again, restoring provider-scoped runtime_default selection that
  the startswith("claude-") narrowing silently dropped.
- config/validator: derive _KNOWN_AGENT_FIELDS from AgentConfig only (plus
  the tolerated `name` alias). Unioning TaskConfig fields let task-only keys
  (expected_output, output_file, async_execution, context, condition) pass
  agent validation even though the agent runtime ignores them.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 08:40:42 +00:00
praisonai-triage-agent[bot]andMervin Praison 8faba59dcc test(mobile): pin main.ts's UI seam to the barrel by symbol identity
The boundaries rule permits app -> ui by either the barrel or a deep path,
so the wiring this PR established could regress to deep imports silently.
Add two behaviour assertions (per the package's house rule of not grepping
source): main's UI symbols resolve to the same identities the barrel
re-exports, and the barrel exposes every symbol main.ts pulls from it.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 08:37:54 +00:00
praisonai-triage-agent[bot] 41cb2d5a57 Merge pull request #4641 from MervinPraison/claude/issue-4639-20260902-0748
fix(mobile): remove dead approval skip guard in praisonai-ts conformance
2026-09-02 08:37:10 +00:00
praisonai-triage-agent[bot]andMervinPraison 0bba90950a fix: single-source YAML fields, data-driven provider inference, drop third integration registry (fixes #4650)
Gap 1: derive YAML known-field sets from the Pydantic schema (YAMLConfig/
AgentConfig/TaskConfig) instead of three hand-maintained sets; adapters now
declare consumed fields via a SUPPORTED_YAML_FIELDS class attr; known_optional
tools derive solely from praisonaiagents.tools.TOOL_MAPPINGS.

Gap 2: add entry-point-extensible resolve_provider() to the core SDK
(praisonaiagents.llm.model_providers) and thin the wrapper adapter, so any
declared vendor (groq, deepseek, mistral, ...) resolves provider-scoped
runtime_default instead of only three hardcoded vendors.

Gap 3: delete the third IntegrationRegistry (_unified_registry.py); the public
surface is served by a static name->loader map plus the two typed registries
(ExternalAgentRegistry, ManagedBackendRegistry) and the praisonai.integrations
entry-point group. Add PluginRegistry.has() for cheap membership checks.

Regenerated agents.schema.json for the added top-level fields.

Co-authored-by: MervinPraison <[email protected]>
2026-09-02 08:33:58 +00:00
praisonai-triage-agent[bot]andMervin Praison 42a085990c fix: make AgentMail idempotency_key an explicit param for durable detection
Elevate idempotency_key from **kwargs to an explicit send_message()
parameter so DurableDelivery._send_accepts_idempotency_key() can detect
and forward its persisted key (Greptile/Qodo feedback). Backward
compatible: legacy **kwargs entry still honoured; default remains a
generated UUID.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 08:31:50 +00:00
praisonai-triage-agent[bot]andMervin Praison 2747fb2e1e test(observability): assert Arize npm package metadata
Adds a unit test verifying OBSERVABILITY_TOOLS.arize.package resolves to
@arizeai/openinference-core, addressing Qodo review feedback.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 08:24:02 +00:00
praisonai-triage-agent[bot]andMervin Praison d4aab0078a docs(mobile): sync gaps.md with approval conformance change (#4639)
approval is no longer unsupported for praisonai-ts -- its case only decides
an unknown id, which needs no approval channel. Only two_approvals stays
unsupported. capabilities.approvals remains false (the engine still cannot
emit an approval request).

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 07:55:15 +00:00
praisonai-triage-agent[bot]andMervinPraison 6bad68b740 fix(mobile): wire ui/src/index.ts barrel into the real import graph (fixes #4635)
main.ts reached the UI layer through five deep imports, so the barrel
ui/src/index.ts -- the module the layer documents as its single outward
surface -- had no importer and was never loaded by the application.
Route main.ts's UI imports through the barrel so it is on the real import
graph and the webview bundle exercises it. Tracked under #4640.

Co-authored-by: MervinPraison <[email protected]>
2026-09-02 07:54:44 +00:00
praisonai-triage-agent[bot]andMervin Praison fbe9703ebe fix: restore saved Train view once engine is ready after setup (#4441)
The engine gate forces Chat (without persisting) so setup/failure UI is
visible when a restored Train view would hide it behind #thread. But the
boot() ready branch never re-applied the saved view, so after setup
succeeded the user was stranded on Chat until relaunch.

Extract restoreView() and call it in the ready branch so a deliberate
Train choice returns once the engine is up. Adds a regression test that
completes setup with savedView=train and asserts Train becomes visible.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 07:53:45 +00:00
praisonai-triage-agent[bot]andMervin Praison bedfc05d32 fix: show setup UI when engine not ready and Train view is restored (fixes #4441)
A restored Train view sets body.training, which hides #thread. When boot()
async-resolves to "setup needed"/"failed", firstRun()/failWithSetup() render
into #thread behind the visible Train form -- the title bar says "setup needed"
with no way to reach the wizard. Force the chat view (without persisting the
saved Train choice) before rendering setup/failure UI so it is on screen.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 07:53:16 +00:00
praisonai-triage-agent[bot]andMervin Praison ada5c4f7ff fix: pass stable AgentMail Idempotency-Key across outbound retries (fixes #4621)
deliver_outbound() retries messages.send()/reply() up to 3 times, but neither
call supplied AgentMail's provider-level Idempotency-Key header, so a
timeout-after-send could duplicate the email at the provider boundary.

Derive one stable key per logical send before _do_send() and pass it via
request_options.additional_headers to both send and reply, so every retry
reuses the same key. Callers may pass idempotency_key for deterministic replay.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 07:52:10 +00:00
praisonai-triage-agent[bot]andMervinPraison 13f187a8c4 fix(mobile): delete unreachable ui/src/index.ts barrel (fixes #4635)
The ui/src barrel had zero importers -- not one source file, not one
test. Every module it re-exported is imported directly where used and
tested directly, so deleting it loses no coverage (1278 tests still
pass) and makes the previously-silent dead code an explicit state.

Co-authored-by: MervinPraison <[email protected]>
2026-09-02 07:50:47 +00:00
praisonai-triage-agent[bot]andMervin Praison 3ccdfd15a4 fix: correct Arize observability npm package metadata (fixes #4620)
Replace nonexistent 'arize-phoenix' (PyPI name) with the actual
@arizeai npm package '@arizeai/openinference-core' in the observability
tool metadata and the fallback install warning.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 07:50:38 +00:00
praisonai-triage-agent[bot]andMervinPraison a9df113433 fix(mobile): remove dead approval skip guard in praisonai-ts conformance (fixes #4639)
The `approval` conformance case only decides an unknown id and asserts
false, which praisonai-ts's decide() always returns -- so declaring
`approval` unsupported skipped a case that already passed, leaving the
`if (skipping("approval"))` guard dead. Run the case instead by scripting
`approval` as a plain run; keep `two_approvals` unsupported since it
genuinely needs two approval_request events this engine cannot emit.

Co-authored-by: MervinPraison <[email protected]>
2026-09-02 07:50:14 +00:00
praisonai-triage-agent[bot] 679e4d7738 Merge pull request #4624 from MervinPraison/claude/issue-4456-20260902-0655
feat: add SkillAutomation frontmatter contract to bridge skills → scheduler
2026-09-02 07:48:52 +00:00
praisonai-triage-agent[bot]andMervin Praison 56f58eb9c2 feat: add SkillAutomation frontmatter contract to bridge skills → scheduler (fixes #4456)
Adds an optional, protocol-only automation descriptor to a skill's SKILL.md
frontmatter (metadata.praisonai.automation) so a skill can declare a schedule,
delivery target and prompt. Materialising the accepted job stays in the
wrapper's existing consent-first SuggestionEngine + DeliveryRouter — no new
object type, delivery path, consent model, or Agent params.

- SkillAutomation dataclass (schedule required; deliver, prompt optional)
  with from_metadata() + to_dict()
- SkillProperties.automation field + to_dict() serialisation
- parser preserves nested metadata mappings (namespaced praisonai block)
- lazy export of SkillAutomation
- tests for models + parser

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 07:03:28 +00:00
Mervin PraisonandClaude Opus 5 f498378b33 test(mobile): the ledger's self-defence protected one case, not twenty-one (#4618)
A fresh sweep of the code this session changed -- 316 mutations over the eight
files it touched -- found the assertion ledger had reproduced, one level up,
the exact structural hole it was built to close.

The self-defence probe in conformance.test.ts deletes ONE assertion from the
engine contract and requires the fixture run to redden on the ledger. So only
the case containing that assertion has its own `rawAssert.equal(made() -
made0, N)` defended. Measured against the FULL suite: 18 of 21 ledger checks
could be deleted with `npm run check` still exiting 0, and every assertion in
those cases was free again.

Which is the same thing a break mode does: protect the first thing it happens
to reach.

Every case now CLOSES itself -- it either counts its assertions or declares
itself skipped -- and a final case asserts that all 21 did exactly one of the
two. Re-swept: 0 of 21 survive.

Four skip guards still survive deletion and are not gaps: `empty`,
`tool_failed` and `auth_error` are declared unsupported by no harness, so
those guards are dead until one does; and removing `approval`'s skip leaves
the case passing against praisonai-ts anyway, which says the map entry is
documentation rather than a requirement.

Three more, each confirmed against the full suite first:

- `spokenDropped = turn.dropped.length` was removable. The cursor never
  advances, so every later publish of the same finished turn re-announces the
  same decoder gap -- and it is assertive, so it interrupts whatever else the
  screen reader was saying, on a loop. Paired with a test that a SECOND gap is
  still reported, so a latch cannot pass instead of a count.

- The parity checker's SKIP path had no test at all. Dropping its
  `process.exit(0)` makes it carry on without the file it exists to check, and
  making `exists` answer true for a missing path does the same from the other
  end. Either way a standalone clone's CI fails for a reason that has nothing
  to do with drift. UPSTREAM is now injectable, as AGENT_API already was.

- The drift lines could stop being printed. The job still goes red, so the
  gate holds -- but with nothing about WHICH member changed, which leaves a
  failing gate nobody can act on. The exit code is the gate; the message is
  what makes it fixable.

- `await rm(dist)` was removable because every test built into a fresh temp
  directory where dist did not exist. On a real machine it does, so a renamed
  or deleted asset stays in the shipped output and the page keeps loading a
  file that is no longer in the source.

Not gaps, proved rather than assumed: five surviving mutations in the rewritten
SSE reader are EQUIVALENT across 1157 (stream, chunking) pairs -- parseBlock
skips empty lines, so a stray leading or trailing newline is invisible. Eight
more were artefacts of my own sweep, which ran `node --test` without `tsc`:
deleting an interface member is a typecheck error, and `npm run check` catches
it.

1278 tests on Node 22.23 and 24.12. Six mutations re-run against the new
tests; all die.

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-31 10:04:04 +01:00
MervinPraison a33413c1e8 docs: auto-update documentation parity trackers [skip ci] 2026-08-31 07:34:13 +00:00
praisonai-triage-agent[bot]andMervin Praison b94ce5ff24 fix: close shared-state races in switch_model, ephemeral/clear_history, and session IDs (#4614)
* fix: close shared-state races in switch_model, ephemeral/clear_history, and session IDs (fixes #4613)

Gap 1: switch_model() now invalidates the cached _unified_dispatcher and
acquires the model-fallback lock, so it can't dispatch on a stale model or
interleave with an in-flight sync fallback.

Gap 2: clear_history() now takes _history_lock like every sibling mutator,
and ephemeral() restores via an id-based diff (removing only the messages
appended inside the block) instead of a blind list reassignment, preserving
any concurrent turn's messages.

Gap 3: auto-generated Session IDs use 64 bits of entropy and regenerate on a
pre-existing session directory; spawn_sub_agent/aspawn_sub_agent retry on
truncated-UUID agent_id collisions.

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

* chore: remove stray knowledge.db generated during testing

* fix: ephemeral() preserves messages committed by concurrent live turns

Greptile P1: ephemeral() kept only pre-block message ids, so a message a
concurrent chat()/achat() turn committed during the block was deleted on
exit (history loss). Now keep a message if it is in the pre-block snapshot
OR owned by a still-in-flight turn, via a process-wide live-turn registry
that _begin/_end/_clear_turn_tracking maintain. Removes only true ephemeral
additions (including direct appends), preserving concurrent turns.

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-08-31 08:33:38 +01:00
praisonai-triage-agent[bot]andMervin Praison 7579e20c41 fix: compile completion-signal regexes once at import (fixes #4609) (#4611)
Hoist _is_completion_signal's six regex patterns to module-level
constants compiled once at import instead of recompiling on every
autonomy-loop iteration. Behaviour is identical.

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-08-31 08:33:07 +01:00
Wu Shuwen 9ca3401f97 perf: compile completion patterns once (#4610) 2026-08-31 08:32:53 +01:00
MervinPraison 78de02c1d7 docs: auto-update feature parity trackers [skip ci] 2026-08-31 07:32:28 +00:00
praisonai-triage-agent[bot]andMervinPraison b54ea1a16d fix: detect unclean/OOM gateway death and persist crash-loop breaker (#4604)
* fix: detect unclean/OOM gateway death and persist crash-loop breaker (fixes #4603)

Add a small, durable, boot-scoped process-lifecycle record so a hosted
gateway can tell on its next boot that its previous process died uncleanly
(OOM-kill/SIGKILL run no signal handler, so signal-based forensics never
fire), surface coarse memory/disk pressure on GET /health, and make the
restart-loop breaker survive the very restart it guards.

Core (pure, zero-I/O, mirrors RestartLoopGuard/HealthPressure):
- LifecycleRecord + classify_unclean_exit()
- RestartStoreProtocol + PersistentRestartLoopGuard
- classify_resource_pressure()

Wrapper (praisonai_bot.gateway.lifecycle_ledger, on by config):
- LifecycleLedger: durable sentinel restamped at boot/shutdown, /proc+cgroup
  memory sample folded in by a heartbeat, ownership-checked clean-exit marker
- SentinelRestartStore backing the persistent breaker off the same file
- Wired into start()/stop()/heartbeat and /health (lifecycle + pressure blocks);
  boot-time auto-resume is suspended when the persistent breaker trips while
  fresh inbound is still served.

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

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

* fix: stamp clean exit on graceful serve() teardown + merge health pressure blocks (#4603)

Address both Greptile P1s on PR #4604:

1. Graceful SIGINT/SIGTERM shutdown returns through serve()'s finally without
   calling stop(), so the lifecycle sentinel stayed "running" and the next boot
   misreported a clean shutdown as an unclean/OOM death. Stamp the ownership-
   checked, idempotent clean-exit marker in the serve() finally (after cancelling
   the sampler) so the normal shutdown path is recorded clean.

2. When both the lifecycle ledger and the saturation back-pressure machinery are
   enabled, the saturation _compute_pressure().to_dict() overwrote the ledger's
   {memory, disk} pressure block on /health. Merge the two disjoint vocabularies
   onto one pressure object so both OS-resource and admission/backlog signals
   survive.

Adds two server-integration regression tests.

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

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-08-31 08:31:55 +01:00
praisonai-triage-agent[bot] 7277a067f9 Merge pull request #4616 from MervinPraison/claude/issue-4615-20260830-0828
fix: async-store write drops, per-agent SDK client churn, resolver CWD leak
2026-08-30 09:24:12 +00:00
praisonai-triage-agent[bot]andMervin Praison ec5aefaae2 fix(models): do not close SDK client on LRU eviction (Greptile P1)
Evicted cache entries are handed to CrewAI/etc. as an agent's llm and
retained for the whole run; closing on eviction shut a transport still
in use once a worker resolved >64 distinct model/endpoint/credential
combos. Drop the LRU reference only and let GC release the httpx pool
after the last external holder goes away. Race-loser close is retained
since that client is never handed out.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-30 08:38:49 +00:00
praisonai-triage-agent[bot]andMervinPraison f065af85e0 fix: async-store write drops, per-agent SDK client churn, resolver CWD leak (fixes #4615)
Gap 1: sync DB hooks silently dropped every persistence write when an async
store ran on a live event loop (run_sync_or_offload closed the coroutine and
raised, swallowed as a warning). _call_store now submits the write to the
shared bridge as tracked fire-and-forget on a running loop, blocks on the
bridge off-loop, and flush_pending_writes() drains in-flight writes from
close()/aclose().

Gap 2: PraisonAIModel.get_model() rebuilt a fresh SDK client (and httpx pool /
TLS context) per agent build. Added a bounded LRU cache keyed by
(model, base_url, api_key_fingerprint); _build_client() extracts construction;
evicted clients are closed. Registered providers are never cached.

Gap 3: _get_default_resolver() wrote into the current ContextVar, leaking the
first caller's CWD into the root context process-wide. The accessor no longer
mutates the ContextVar; unscoped callers get a CWD-anchored LRU-cached
resolver, and scoped_resolver() provides an explicit binding for request/task
scopes.

Co-authored-by: MervinPraison <[email protected]>
2026-08-30 08:33:35 +00:00
MervinPraison c5c4624ee9 docs: auto-update feature parity trackers [skip ci] 2026-08-30 07:52:12 +00:00
praisonai-triage-agent[bot] d012730209 Merge pull request #4612 from MervinPraison/claude/issue-3866-20260830-0702
fix(hooks): fail closed on unexpected exit codes and missing hook callable
2026-08-30 07:51:43 +00:00
praisonai-triage-agent[bot]andMervin Praison 74a5ffa014 fix(hooks): deny allow-JSON emitted before an unexpected exit code
A command hook that printed valid allow-JSON but then exited with an
unexpected code (e.g. 127) still allowed the guarded operation because
_parse_command_output returned the JSON verdict before checking the exit
code. Now an allow decision paired with an unexpected exit code fails
closed to deny, while explicit deny-JSON and clean (0/2) exits are
unchanged. Fixes the Greptile JSON-bypass finding on #3866.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-30 07:11:05 +00:00
praisonai-triage-agent[bot]andMervin Praison 7ce6b4d053 fix(hooks): fail closed on unexpected exit codes and missing hook callable (fixes #3866)
The BEFORE_TOOL hook gate could silently fail open: a command hook that
exited with an unexpected code (127 command-not-found, 126 not-executable,
etc.) mapped to decision="allow", and a FunctionHook with func=None set no
output, so is_blocked() returned False and the tool ran anyway. Both now
deny, matching the fail-closed posture already used by the exception/timeout
paths and GuardrailChain.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-30 07:05:34 +00:00
Mervin PraisonandClaude Opus 5 4b826738c3 perf(mobile): two quadratics on the streaming path, measured and fixed (#4608)
Both are quadratic in the length of ONE answer, both block the main thread of
a phone, and neither was visible to any test.

SSE buffering, 163ms -> 1.4ms for a 1MB frame over 512-byte chunks.

`buffer += chunk` then `buffer.indexOf("\n\n")` re-scans -- and, because the
append builds a rope that indexOf must FLATTEN, re-copies -- the whole buffer
on every chunk. A tool_result carrying a big file over a cellular link is
exactly that shape. Measured 2.4 / 10.7 / 40.7 / 163.1 ms at 128 / 256 / 512 /
1024 kB: 4.01x per doubling.

Adding a scan offset was tried first and did NOT help at all, which is what
identified the flatten rather than the search as the cost. So the search now
runs over the arriving chunk alone and the pieces are joined only when a frame
completes -- once per frame, over that frame's own length. A boundary that
straddles the join between two pieces is invisible to that search and is
handled explicitly.

1 / 2 / 4 MB over 1kB chunks: 100 / 288 / 1185 ms before, 1.3 / 2.0 / 2.8 ms
after. Many small frames -- the common case -- are unchanged.

The rewrite is proved IDENTICAL to the old reader across 932 (stream,
chunking) pairs: LF, CRLF, bare CR, a CRLF followed by a bare LF, comments,
folded data, id and retry, no-colon lines, blank runs, unterminated tails,
every single split index, and each of those with an empty chunk wedged in.

Two mutations of the new code survive and are proven equivalent by that same
probe rather than assumed: parseBlock skips empty lines, so a stray leading or
trailing newline in a block is invisible. Both are noted in the source so the
next reader does not take them for missing tests.

Screen-reader announcements, 175ms -> 4.5ms for a 160kB answer.

`completedLength` segments the whole accumulated answer. `lastStreamAtMs`
advanced only when something was actually SPOKEN, so any stretch where no
sentence completes -- a markdown table, a code block, a JSON dump, a bulleted
list -- left the rate limit permanently open and re-segmented everything on
every publish.

Moving the assignment was not enough on its own, and measuring is what caught
that: the identity short-circuit `if (out.length === 0 && base === state)`
returned the ORIGINAL state and threw the advance away. 533 full
segmentations over 607 publishes, still. That short-circuit also discarded a
`spokenChars` advance whose completed prefix trimmed to empty -- the same bug
in the other cursor. It now returns the same object only when nothing moved.

The cost: a sentence completing just after a check waits up to
ANNOUNCE_INTERVAL_MS. That is what the rate limit is for, and it is what
already happened whenever speech was flowing.

One existing test asserted state identity after an interval had elapsed. The
only caller does not use identity -- it guards on announcements.length -- so
that test was pinning an optimisation nobody consumes and blocking a 39x fix.
Rewritten to assert what the caller actually depends on.

1257 tests on Node 22.23 and 24.12. Nine mutations, including reverting each
fix, all die.

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-29 11:51:47 +01:00
Mervin PraisonandClaude Opus 5 d3fc55c69e fix(mobile): an RTL locale with a Unicode extension was laid out left to right (#4607)
A LIVE BUG, not a mutant. `directionFromTables` scanned every subtag for a
four-letter one and read it as the script. `ar-EG-u-nu-latn` -- an Arabic
phone reporting a Unicode numbering-system extension, which is what a real
device sends -- matched `latn` inside the extension and the entire UI was
laid out left to right. Same for `fa-IR-u-nu-latn` and `ur-PK-u-nu-latn`.

Invisible on any host with `Intl.Locale.textInfo`, because `direction()`
answers from ICU first and falls back to this table only where ICU is ABSENT
-- the older Android WebView the table exists to serve.

Per BCP 47 a script subtag sits immediately after the language and nowhere
else, so the fix reads position 1 only. A "stop at the first singleton" pass
was written first and then REMOVED: it fixed the same case independently, so
the two guards were mutually redundant and no test could show that either one
mattered. One mechanism that can be tested beats two that cannot.

The drift test against ICU is widened to tags carrying extensions, with a
non-vacuity floor -- a drift test on a host where ICU answers nothing passes
while comparing nothing.

Also, ten survivors in app/src/dom.ts, all the same shape: the tests reached
rows by `dataset`, never by what a person reads.

- Every approval button's label was removable, and all three could be given
  the same one. The user taps the button they believe says Deny and sends
  `allow`. Every existing test found its button by `dataset["choice"]`, which
  the mutation leaves intact.
- The approval question was removable: the user authorises a command the
  prompt does not name.
- The tool name could become the call id; the output preview block could be
  removed entirely; the preview could become the full 40kB output.
- `row row-${kind}` was removable, so every `.row-tool` / `.row-approval` /
  `.row-error` rule in app.css stops matching and the transcript renders as
  undifferentiated grey boxes.
- The notice tone was removable, so the Stopped notice reads "warning".

The streaming-class toggle could not have been caught before: this file's
fake document had `classList: { toggle() {} }` -- a fake that accepted every
call and recorded nothing. It is now backed by className, as a browser's is.

And three in main.ts's accessibility wiring. `polite`'s aria-live was pinned;
`assertive`'s was not, so the region carrying approval prompts and errors
could go polite -- and an approval BLOCKS the run, so waiting for the queue to
drain waits for something that will not happen until the user answers.
Nothing asserted the transcript is NOT a live region, which its own comment
calls the catastrophe. And the composer's label could be given the Send
button's name: the exact regression that comment records as fixed.

1229 tests on Node 22.23 and 24.12. Every mutation named above re-run against
the new tests; all die.

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-29 11:48:53 +01:00
Mervin PraisonandClaude Opus 5 47f2b39fdc test(mobile): close 14 protocol and engine defects, and fix a test that held for the wrong reason (#4606)
A mutation audit of protocol/, engines/ and adapters/ ran 107 mutations and
found 13 genuine survivors. All were re-verified against clean main first.

THE HEALTH RULE, which readiness.ts exists for, was not held. `body.ok !==
true` -> `!body.ok` survived: `{"ok": 1}`, `{"ok": "yes"}`, `{"ok": "false"}`
all read READY at HTTP 200, and a chat is routed into a broken engine.

The test meant to catch that DID feed it `{ok: "yes"}` and `{ok: 1}` -- and
passed anyway, because it asserted only `ready === false` and those bodies
carry no `version`, so they fell through and were refused as
`version_mismatch` instead. The assertion held for the wrong reason, which is
the same as not holding. Every body now carries a valid version and the
REASON is asserted.

Same class in the same module: an unhealthy engine's `retryable: true` could
become false, declaring an engine that is still binding its socket
permanently dead so a connection manager stops polling.

Cancel and approve were free to lie:

- `["ok"] === true` -> `!== false`: `{}`, `{"ok":null}`, `{"ok":0}`,
  `{"error":"unknown run id"}` all read as SUCCEEDED. The status-code test
  covered non-200 only. The source comment calls this "a lie the UI cannot
  detect"; nothing detected it.
- the `catch` returning true: a dropped connection on the way to /cancel
  reports a successful stop.

`classify.ts`: deleting `authentication` from the auth alternation survived,
because the case written to pin it -- `"authentication_error: invalid
x-api-key"` -- also matches `invalid[ _-]?x?-?api[ _-]?key`. Bare
`authentication_error` sends a bad key to "try again" instead of to the
settings screen: the exact regression the source comment records as fixed.

The SSE parser had never been given a line without a colon (`data` alone is a
legal empty data field per the spec) or an `id:`-only frame -- the field that
becomes the resume cursor. `retry` had a test; `id` did not.

`checkProtocol` could report `engine: 0` for an absent version, in a function
whose own comment says a missing field must never be mistaken for a present
one, and could report `expected: engine`, producing the self-contradictory
"too_old: engine=1 expected=1" that a user copies into a bug report.

Two adapters: the web shell's `Math.max(0, ...)` clamp had no test because
the port fake pins `offsetTop` to 0 and never lets the viewport exceed
`innerHeight` -- both real states on a phone, and both make the composer sit
off the bottom of the screen. And the Tauri bridge's "both, not either" check
could become `&&`, so a half-present global reads as native and every shell
subscription silently becomes a no-op.

Every negative case is paired with a positive one. 1235 tests on Node 22.23
and 24.12.

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-29 11:36:20 +01:00
7e9a7fbc5b test(mobile): close 15 core defects a mutation audit found (#4605)
* test(mobile): close 15 core defects a mutation audit found

Every one survived the whole core suite. Verified individually against clean
main before the tests were written, and each test re-run against the mutation
it exists for.

The controller builds a RunRequest and calls cancel(), and nothing had ever
asserted what was IN either:

- `engine.cancel(run.runId)` -> `cancel(chatId)`. The engine is asked to
  cancel an id it has never seen and returns false, so the user's Stop does
  nothing while the model keeps generating and billing.
- `attachments: prompt.attachments` removable. The user attaches a photo,
  taps Send, and the model answers as though there were no image.
- `tools: true` -> `false`. Every tool is silently off; the model says it
  cannot run commands and no tool card ever appears.
- `stopTicking()` removable. Every turn leaks a live 16ms interval that never
  stops -- twenty messages, twenty timers, forever.

The settings store is the one thing that reads a file a user can hand-edit:

- the scalar typeof guard removable: an object loads into the store and the
  screen renders "[object Object]".
- `if (validated !== null)` removable: `temperature: "abc"` loads as null AND
  isSet reports null as the user's deliberate choice.
- `clearSecret`'s notify() removable: the user taps Clear, the key is gone,
  and the row still reads "configured". Only setSecret's was tested.
- the facade's `isSet` collapsible to `() => true`: every setting reports as
  chosen, so a def's default outranks the composition root's explicit engine
  argument -- the exact override isSet exists to prevent.

And four more:

- `repository.load`'s array guard: `messages: "hi"` is spread as four
  one-character messages, so the indices `end` reports address the wrong ones
  and Fork and Delete then act on junk.
- its title guard: a chat with no title is a blank, unidentifiable list row.
- `approvals.reject`'s `status !== "sending"` guard: a late failure flips an
  already-confirmed decision back to a live prompt for a command that has
  already run. `acknowledge`'s identical guard was tested; reject's was not.
- `titleFrom`'s `[...line]`: a title is cut through a surrogate pair and the
  chat list row ends in a replacement character.
- `isOpenableExternally`'s `.toLowerCase()`: `HTTPS://` and `Mailto:` are
  legal per RFC 3986 and became dead links. Fails closed, so no hole.

Every negative case is paired with a positive one, so an implementation that
refused everything cannot pass. 1233 tests on Node 22.23 and 24.12.

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

* fix(mobile): correct message-count in repository test comment

Spreading "hi" yields two one-character messages, not four. Fixes the
misleading explanatory comment flagged by Greptile.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-08-29 11:36:10 +01:00
Mervin PraisonandClaude Opus 5 884a09888b test(mobile): pin the engine contract's assertion counts, and test the ledger (#4602)
The adapter contracts got a counting ledger because a break mode protects
only the first assertion to trip in its case. The engine contract has the
same shape and the same fixture, and measured WORSE: deleting each of its 32
single-line assertions one at a time and running the whole engines suite, 30
survived with a fully green run. Five break modes were guarding two
assertions.

This is the suite I cited as the model when fixing the adapter contracts.

Per case rather than one total, because a harness declares which scenarios it
cannot support and skips them, so no single total is right for every engine.
Each case opens with `const made0 = made()` and closes by asserting the exact
number of assertions it made. Three counts are not constants and say so: the
two capability-gated cases assert nothing when the engine HAS the capability
-- the case exists for the other branch -- and `dispose is idempotent` makes
zero assertions by design, passing by not throwing. Pinned anyway, so adding
one there is a decision rather than an accident.

Re-measured after: 0 of 32 survive.

The ledger itself had no test, and that mattered: removing `made += 1` from
the `apply` trap survived the entire engines suite, because no contract
happens to use a bare `assert(x)`. Seven tests now cover it, and seven
mutations against them all die -- including two the first draft missed:
reading `assert.AssertionError` to check the non-function guard proves
nothing (a class is a function either way; `name` and `length` are the
properties that distinguish), and truncating the apply trap's arguments went
unnoticed until a bare `assert(false, "why")` was asserted to keep its
message.

engines/ and adapters/ carry separate copies of the ledger: engines may not
import adapters and the layer rule is right to say so, and neither belongs in
core, which ships. The fixture pattern is already duplicated for that reason.

1216 tests pass on Node 22.23 and 24.12.

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-29 10:49:23 +01:00
MervinPraison 3f6a5eb095 docs: auto-update documentation parity trackers [skip ci] 2026-08-29 09:41:01 +00:00
praisonai-triage-agent[bot] 72016e812f Merge pull request #4597 from MervinPraison/claude/issue-4596-20260829-0850
fix: abort in-flight tool body on cooperative interrupt
2026-08-29 09:40:26 +00:00
d427939ee8 test(mobile): count contract assertions, so none can vanish silently (#4599)
* test(mobile): count contract assertions, so none can vanish silently

contract-fixture.ts proves a contract still CONTAINS the assertion that
catches a given defect: it registers a deliberately broken adapter and
requires the matching case to fail by name. That works, and it caught real
hollowing. But it protects exactly one assertion per break mode -- the first
one in that case to trip. Every other assertion in the same case stays free,
because the case keeps failing on the one before it.

Measured, by deleting each single-line assertion in the four adapter
contracts one at a time and running the whole adapters suite: 62 of 73 could
be removed with a fully green run. Thirteen break modes were protecting
eleven assertions. The failure mode is silent -- the run reports one test
fewer and nothing else.

Sixty more break modes is not the answer. Instead each describeXContract now
takes a counting proxy over node:assert/strict, and its last registered case
asserts the exact number of assertions the earlier cases made. Delete one and
the count is short and the case fails by name. Add one and the constant has
to be updated deliberately -- which is where you notice the new assertion
also wants a break mode. The count is exact rather than a floor, because a
floor lets an assertion be swapped for a weaker one at the same arity.

That makes the ledger's own check the new single point of failure, so it
defends itself: four tests delete a real assertion from a real contract, run
the fixture, and require it to go red ON THE LEDGER rather than incidentally.
The contract is edited in place and restored in a finally, with a byte-for-
byte check that it was. A probe module was tried first and rejected twice
over: a temp tree breaks the contracts' relative ../../../core imports, so a
"failure" would prove only that the file never loaded, and a dynamic import()
needs top-level await, which the boundary scanner's esbuild pass refuses --
loosening a gate to make a test of a gate work is the wrong direction.

Re-measured after: 73 of 73 caught, 0 survivors. 1201 tests pass on Node
22.23 and 24.12. The five new tests were each re-run against the mutation
they exist for; all die.

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

* fix(mobile): drop unused rmSync import from contract ledger test

The mutation-based ledger test only uses readFileSync/writeFileSync; the
unused rmSync binding obscured the filesystem operations it actually
performs. Flagged by Greptile.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-08-29 10:33:52 +01:00
praisonai-triage-agent[bot]andMervin Praison 156e29c389 fix: propagate cancel to async tools + prevent stale-interrupt leak
Address PR #4597 review (Greptile P1/P2):
- async path: establish injection context (incl. cancel_event) in
  _execute_tool_async_impl so achat()/astart() shell tools abort promptly
  instead of running until timeout. Shared _build_agent_state helper avoids
  drift with the sync execute_tool path.
- interrupt: clear the underlying Event when an interrupted turn ends so a
  reused controller does not abort tools launched by the NEXT turn.
- protocol: make `event` an optional capability via
  CancellableInterruptControllerProtocol; base protocol stays event-free so
  existing third-party controllers remain valid.
- tests: stale-interrupt clear, sibling-turn retention, optional-protocol.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-29 09:04:41 +00:00
MervinPraison 93a35a2a60 docs: auto-update api.md [skip ci] 2026-08-29 09:02:13 +00:00
praisonai-triage-agent[bot] 4a72a65afd Merge pull request #4595 from MervinPraison/claude/issue-4594-20260829-0816
fix: deprecation-bypass on top-level DB aliases + document global security scope
2026-08-29 09:01:43 +00:00
praisonai-triage-agent[bot]andMervinPraison 1c4ae5daea fix: abort in-flight tool body on cooperative interrupt (fixes #4596)
Thread the InterruptController's cancellation Event into the tool
invocation context (AgentState.cancel_event / InvocationContext.cancel_event)
so a running tool body can observe an interrupt and abort promptly instead
of running until its own timeout. The built-in shell tool now polls the
event while its subprocess runs and kills the process group on cancel,
returning an `interrupted` outcome. Fully backward compatible: a tool that
ignores the event behaves exactly as before (no new hard deps, lazy imports).

Co-authored-by: MervinPraison <[email protected]>
2026-08-29 08:55:02 +00:00
praisonai-triage-agent[bot]andMervin Praison 946e8b58ee test: lock in top-level DB alias deprecation warnings (fixes #4594)
Add regression coverage for `from praisonai import DB/PraisonAIDB/PraisonDB`
so a future return to the `db.adapter` import path (which silently bypasses
the DeprecationWarning) fails CI. Addresses Greptile's flagged coverage gap.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-29 08:25:14 +00:00
MervinPraison 25d8279c62 docs: auto-update feature parity trackers [skip ci] 2026-08-29 08:24:18 +00:00
Mervin Praison 81e51f1d34 Merge pull request #4593 from MervinPraison/claude/issue-4592-20260829-0710
fix: async-safe chat-history rollback & model-fallback on shared Agent (#4592)
2026-08-29 09:23:50 +01:00
Mervin Praison ccd2138935 Merge pull request #4586 from MervinPraison/fix/windows-fileversion-gate
fix(desktop): the Windows version gate rejected a correctly stamped binary
2026-08-29 09:23:14 +01:00
praisonai-triage-agent[bot]andMervinPraison a32acfa114 fix: route top-level DB aliases through deprecating db module + document global security scope (fixes #4594)
Gap 1 (minimal): praisonai.__init__ resolved DB/PraisonAIDB/PraisonDB
directly from db.adapter, bypassing the DeprecationWarning that the
praisonai.db import path emits. Now route through praisonai.db so
`from praisonai import DB` warns consistently, matching
`from praisonai.db import DB`.

Gap 2 (docs-only): document that enable_injection_defense/enable_audit_log/
enable_security register on the process-global hooks registry and are not
tenant-scoped, with guidance to remove_hook for teardown. No new
params/modules/exports (avoids Agent bloat and scope creep per AGENTS.md).

Larger refactors proposed in the issue (deleting storage/, new SecurityPolicy
protocol on Agent, orchestration middleware/GeneratorContext rewrite) are
intentionally not implemented as scope creep.

Co-authored-by: MervinPraison <[email protected]>
2026-08-29 08:19:17 +00:00
praisonai-triage-agent[bot]andMervin Praison 8e8347997e fix: eliminate shared-Agent async model-fallback race (PR #4593 review)
Async fallback previously swapped the shared self.llm / self._unified_dispatcher
and held a lock across the await. Ordinary concurrent async turns on the same
Agent read those fields WITHOUT the lock, so they could dispatch against another
turn's fallback model (wrong response, cost, and observability attribution) —
the greptile P1 finding.

Instead of mutating shared state, the async fallback now builds a per-call
throwaway dispatcher (_build_fallback_dispatcher) bound to the fallback model and
passes it via _execute_unified_achat_completion(..., _dispatcher=...). Shared
self.llm / self._unified_dispatcher are never touched, so concurrent turns stay
on the original model. No lock is needed on the async path, removing the prior
asyncio.Lock deadlock-avoidance dance. The sync path is unchanged (it recurses
through the full _chat_completion wrapper which reads self.llm for hooks/budget)
and keeps the serialised swap.

Adds two deterministic regression tests: the throwaway dispatcher is a distinct
object bound to the fallback model and leaves shared state untouched, and the
async fallback branch runs the retry on the override dispatcher without mutating
self.llm / self._unified_dispatcher.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-29 07:29:08 +00:00
praisonai-triage-agent[bot]andMervin Praison 2e26dc1ed9 fix: wire chat-history turn tracking to real append path and guard model-fallback swaps (fixes #4592)
Gap 1: _append_to_chat_history (the append path every real chat()/achat()
turn uses) now records into the per-turn _active_turn_owned tracker, and
_rollback_chat_history_to uses `if owned is not None` so an empty-but-tracked
turn still takes the ownership-safe (identity-based) rollback branch instead
of the unsafe positional delete that could erase a concurrent turn's messages.

Gap 2: serialise the Agent-level model-fallback mutate/restore window
(self.llm/_unified_dispatcher) with a lazily-created per-instance dual
(sync/async) lock held across the await, so a concurrent turn on a shared
Agent can never observe the wrong model. Only fallback turns contend; normal
turns stay fully concurrent. Async path releases the lock before recursing
into the error handler to avoid asyncio.Lock deadlock.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-29 07:15:04 +00:00
praisonai-triage-agent[bot] c91410621b Merge pull request #4591 from MervinPraison/fix/mobile-gates
test(mobile): close the five build-gate mutation survivors
2026-08-29 06:50:38 +00:00
MervinPraisonandClaude Opus 5 5ab49a863b test(mobile): close the five build-gate mutation survivors
Every gate in tools/ printed its findings correctly and then let the build
pass anyway, or narrowed silently. Five mutations survived the suite:

- check-upstream-parity: `process.exitCode = 1` -> `= 0`. Upstream API drift
  is reported in full and the job goes green.
- check-upstream-parity: dropping `--strict` from the typecheck. Null-safety
  drift stops being detected at all.
- depgraph: `[^/]*` -> `.*` for a single `*`. Every allowlist entry silently
  widens to cover the subdirectories it was written to exclude.
- depgraph: dropping `matchesAllowlist(name, pkg)` in externalAllowed. A glob
  key like `@tauri-apps/plugin-*` stops matching, so those packages fall
  through to "no rule" and become importable from anywhere -- the Tauri seam
  the rule exists to hold.
- build-webview: dropping `process.exit(1)`. An unshippable bundle is
  published as a dist/ that throws on the first line a browser evaluates.

Three of the five needed a seam to be observable, so the interface path
(PARITY_AGENT_API), the boundaries root, and the webview package root are now
injectable, and the tests drive the real CLIs as processes against fixture
trees. bundle.mjs's relative-external guard is covered directly.

All five mutations now die by exit code. 1187 tests pass on Node 22.23 and
24.12.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-29 06:58:43 +01:00
Mervin Praison 800b2f24cd Merge pull request #4590 from MervinPraison/fix/mobile-secrets-and-consent
fix(mobile): Deny recorded as allow, and three buttons that all said allow
2026-08-29 06:46:33 +01:00
MervinPraisonandClaude Opus 5 cadf875288 fix(mobile): Deny recorded as allow, and three buttons that all said allow
A fifth package-wide sweep put genuine survival at 36.1%, against 17.0% before
it -- and the rise is unambiguous (z=4.77). Nothing regressed: the baseline is
byte-identical and 25 of 27 previously-closed items still die.

THE REASON IS WORSE THAN A REGRESSION WOULD HAVE BEEN

The sweep reconstructed every prior mutation list -- 3,063 records, 1,000
distinct anchors -- and found that 125 of its first 153 mutations landed on
lines a prior sweep had already mutated, 51 of them byte-identical repeats.
Four sweeps had touched 835 of 7,542 lines, and those 835 are essentially the
whole branchy, killable surface.

So it rebuilt the sample: 0 repeats, 104 mutations on lines never anchored
before. That is what moved the number.

  "The suite was never at 17%; the sample was."

The declining trend I have been reporting -- 26.3 -> 23.8 -> 18.5 -> 17.0 --
was substantially re-measuring ground already hardened. The drop was real for
the ground it covered and did not generalise, and I reported it as though it
did.

WHAT THE FRESH GROUND FOUND

  approvals.ts   `choice: entry.state.choice` -> `choice: "allow"`
      An acknowledged DENY is recorded as an ALLOW. The row the user reads back
      says they permitted the thing they refused, and `outstanding` agrees.

  dom.ts         `b.dataset["choice"] = choice` -> `= "allow"`
      All three buttons send `allow`. Every layer above is careful to route a
      decision by approvalId; the last hop decides WHAT the decision is, and
      nothing read it. Deny becomes Allow at the DOM.

  main.ts        `buildTranscript(view.turn, view.approvals)` -> one argument
      The approval TABLE carries the decision lifecycle. Without it the row is
      built from the turn alone and stays `pending` forever -- the user taps
      Deny and the card never acknowledges it.

  registry.ts    `default: 0.7` -> `default: 7`
      `validate` runs on `set` and on `load`, never on the shipped DEFAULT, so
      an out-of-range value is used verbatim on a fresh install. Now asserted
      for EVERY def, because the next one to drift will be a different key.

AND ONE THE SWEEP OVERSTATED, CHECKED RATHER THAN ACCEPTED

It reported `plainOnly(values, byKey)` -> `Object.fromEntries(values)` as
"every secret is written to plain storage". It is not. No public route can put
a secret into the values map: `set` refuses a secret-flagged key and `load`
drops one -- verified by driving all three routes and reading the persisted
file, which contains only `model`. The two forms are equivalent through the
public API and the mutation survives the new test too.

`plainOnly` is defence in depth for the day a new write path forgets the rule,
which is why it is tested directly. The new test asserts the other half: that
no such route exists today, so it fails if one is added.

1178 tests on node 22.23 and 24.12. Four mutations confirmed caught by exit
code; the fifth proved equivalent rather than fixed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-29 06:42:04 +01:00
MervinPraison 3b3455a764 wip 2026-08-29 06:26:04 +01:00
Mervin Praison 492069f61d Merge pull request #4589 from MervinPraison/fix/mobile-sweep9-tier2
fix(mobile): follow-the-stream switched off on every overscroll
2026-08-29 01:44:59 +01:00
MervinPraisonandClaude Opus 5 99a1ccf627 fix(mobile): follow-the-stream switched off on every overscroll
Five more from the fourth sweep's tier 2.

  scroll.ts   the inner `Math.max(0, scrollTop)`
      iOS legitimately reports a NEGATIVE scrollTop while the user
      rubber-bands past the end. Without the clamp the distance from the bottom
      comes out positive, so follow-the-stream switches itself off mid-answer
      on every overscroll -- the exact behaviour the module exists to prevent.
      Paired: a real scroll away from the bottom must still be reported, or the
      transcript yanks the user back down while they are reading.

  composer.ts   `|| event.altKey`
      Under enter-sends -- chosen only on a tablet with a hardware keyboard --
      Alt+Enter SENDS a half-written message instead of breaking the line. The
      comment beside it calls Alt the muscle memory on some layouts.

  remote-http   `encodeURIComponent` on approvalId and runId
      An id containing `/`, `#` or `?` -- and these ids are opaque, so nothing
      forbids them -- posts the decision to a DIFFERENT path. The engine never
      sees it, the run stays blocked until timeout, and the UI shows the
      decision as sent.

  sse.ts   `else if (swallowed) swallowLeadingLf = false` -> `= true`
      The one that took work. It survives every chunking of a pure-CRLF
      stream, because after a swallowed CRLF the next chunk begins with a field
      name rather than a newline. Reaching it needs MIXED terminators: a CRLF
      immediately followed by a bare LF, which is a blank line written two ways
      in one stream, and legal.

      I wrote a test first that could not tell the two apart -- it passed
      against the mutant. A differential probe over 3-cut chunkings of pure
      CRLF found zero differences; widening to mixed-terminator streams found
      exactly one. Correct behaviour is two frames; the mutant swallows the
      leading LF, the blank line disappears, and the two frames MERGE. An event
      boundary is lost silently.

That is the fourth time this session a test of mine passed against the defect
it was written for. The probe is the reason this one did not ship that way.

1171 tests on node 22.23 and 24.12. Five mutations confirmed caught by exit
code.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-29 01:40:32 +01:00
MervinPraison 08bc7d8df2 wip 2026-08-29 01:24:27 +01:00
Mervin Praison d316e85fc0 Merge pull request #4588 from MervinPraison/fix/mobile-contract-hollowing
fix(mobile): a contract case could still exist and assert nothing
2026-08-29 01:20:26 +01:00
MervinPraisonandClaude Opus 5 1a13354fd2 fix(mobile): a contract case could still exist and assert nothing
The structural finding of the fourth sweep, and the one that keeps recurring in
this package: the meta-defence catches a DELETED case and not a HOLLOWED one.
Seven assertions inside the conformance suites could be replaced with nothing
and the build stayed green.

The count floors added earlier assert how many cases RUN. A case that still
runs and asserts nothing passes them, and no break mode covered these seven.
That is the same shape as the original defect -- a check that reports success
over work it did not do -- one level further in.

Five new break modes, each reddening its case BY NAME:

  storage_empty_is_absent        an empty string read back as absent, which is
                                 `||` where the code must use `??` -- it turns
                                 a deliberately-blank setting back into its
                                 default on every read
  time_unsubscribe_does_nothing  the interval leaks for the app's lifetime and
                                 nothing the caller does can stop it
  shell_scheme_case_sensitive    `JAVASCRIPT:` slips past a guard that refuses
                                 `javascript:` -- the oldest trick there is
  shell_negative_insets          a negative inset reaching the layout, which
                                 lifts content off the wrong edge
  abort_ignored                  a run that never consults its signal

AND THE ABORT CASE COULD NOT HAVE CAUGHT ITS OWN DEFECT

`aborting the signal stops the stream` asserted `seen.length < 50`. Every
scenario in the suite is five events or fewer, so an engine that ignored the
signal entirely and ran to completion SATISFIED it -- the case could only fail
for an engine that emitted fifty events, which none does.

It now compares against the length of the full script. A cap larger than any
real run cannot tell stopping from finishing, and that is what an arbitrary
ceiling always is.

Verified by attempting the hollowing that worked before -- gutting the
empty-string case, the unsubscribe case, the uppercase-scheme case and the
negative-inset case to assert nothing, widening the inset bound to -100, and
making the abort ceiling vacuous again. All six now exit non-zero.

1166 tests on node 22.23 and 24.12.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-29 01:16:58 +01:00
Mervin Praison b82969c2f1 Merge pull request #4587 from MervinPraison/fix/mobile-sweep9-tier1
fix(mobile): the wrong tool card, live buttons on a finished run, and gates that could be widened
2026-08-29 00:55:02 +01:00
MervinPraisonandClaude Opus 5 0987b2896a fix(mobile): the wrong tool card, live buttons on a finished run, and gates that could be widened
A fourth package-wide sweep put genuine survival at 17.0% (48/282), against
26.3% -> 23.8% -> 18.5% before it. Honest reading: the fall has essentially
STOPPED. Binomial SE is 2.24pp and the previous 18.5% sits inside this run's
95% interval, so 17.0 and 18.5 are indistinguishable. The drop from 26.3 is
real; the last step is not. Two caveats push the true figure worse, not better:
the sample deliberately avoided the just-hardened lines, and every ambiguous
equivalence call was resolved in the codebase's favour.

13 of 14 spot-checks of previously-closed items still die, so those held.

THE NEW TIER 1

  transcript.ts   `findIndex(t => t.callId === …)` -> `t.name === event.name`
      Two concurrent calls to the same tool -- ordinary for `bash` or `read` --
      and the second's result overwrites the FIRST's row and appends a
      duplicate. The wrong card shows the other call's output and one call is
      stuck "running" forever. The index-pairing defect the whole callId design
      exists to prevent, one layer down.

  reconcile.ts   the approval signature dropping `actionable`
      When a turn ends with an unanswered approval, `settle()` flips
      `actionable` true -> false while `state.status` stays "pending". With
      actionable out of the signature nothing repaints, so the three buttons
      stay LIVE on a finished run and the user taps Allow on something that can
      no longer be delivered.

  controller.ts   `run.abort.abort()` deleted after a successful cancel
      Stop reports success, the reader is never detached, and the stream keeps
      arriving and painting into a turn the user stopped.

  view-model.ts   the empty-text-block guard
      Renders an empty message bubble -- what the file's own comment forbids,
      because it "makes a failure look like a short answer". `decode` rejects
      an empty delta but `apply` is a public reducer and does not, so an
      adapter that does not route through decode produces exactly this.

  web/shell.ts    `if (at !== -1)` before `backSubs.splice(at, 1)`
      A double unsubscribe -- a re-render, StrictMode double-invoking an effect
      -- splices at -1 and removes the LAST element: another screen's back
      handler silently disappears.

  tauri/bridge    the `listen` payload's `target: { kind: "Any" }`
      Changeable to `{ kind: "Window" }`, or removable entirely. On a device
      that is "the app stops receiving safe-area, keyboard, lifecycle and back
      events", silently. My earlier hardening covered `openExternal`'s call
      site and missed this one -- same file, same commit, a different invoke.

THE GATES

`tools/` is still the weakest layer (33.3%), and it is the layer where a
survivor switches off a gate rather than breaking a feature:

  SIZE_BUDGET_BYTES 400kB -> 4MB, and TARGETS moved off the WebView floor
  ungovernedRootsIn no longer counting .mjs, so a new all-.mjs top-level
    directory reports zero files and passes ungoverned
  build-webview's `problems.length > 0` -> `> 1`, so ONE fatal problem ships

The shippability predicate moved into bundle.mjs and is now CALLED by the test
rather than re-implemented in it. Re-implementing would prove a predicate of
that shape works, not that the CLI still contains it -- the exact weakness this
package found in its own conformance meta-tests.

AND A FLAW IN MY OWN VERIFICATION

While checking these I found that `npm run check` can report `fail 0` while
three tests did not pass: node counts them as `cancelled`, not `fail`. I had
been grepping `^ℹ fail`, so two of these nine looked like survivors when the
run had actually exited 1. The reliable signal is the EXIT CODE, and every
result above is now measured that way.

That cuts against me twice over: some earlier "survivor" readings in this
session may have been false, and a harness that reads the wrong counter is the
same defect class as everything else it was hunting.

1161 tests on node 22.23 and 24.12. Nine mutations confirmed caught by exit
code.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-29 00:44:27 +01:00
MervinPraison 46eb315f06 wip 2026-08-29 00:39:12 +01:00
MervinPraisonandClaude Opus 5 38867b0ec2 fix(desktop): the Windows version gate rejected a correctly stamped binary
v4.7.4 shipped with no Windows installer. The release was cut specifically to
deliver the Windows fixes, and the Windows leg is the one that failed:

    FileVersion 4.7.4 does not match 4.7.4.0

The stamping was right. `ProductVersion` read 4.7.4, which is the defect #4527
reported and #4447 fixed. The gate then appended a literal ".0" to the tag and
compared strings, so a binary carrying exactly the version it was asked to
carry was rejected for not spelling it with a fourth component.

It now parses both sides as versions and compares major, minor and build.
`[version]` normalises a missing fourth component to -1, so "4.7.4" and
"4.7.4.0" compare equal on the parts that mean anything, while 4.7.3, 4.8.4 and
0.1.0 all still fail. Six cases checked, including those three.

The existing test required the broken comparison -- it asserted
`$version.FileVersion -ne $numeric` literally -- so it pinned the bug and would
have failed anyone who fixed it. A test that mandates the wrong behaviour is
worse than no test, because it converts a one-line fix into an argument. It now
asserts the comparison parses versions, and that the ".0" concatenation is
gone.

pwsh is not installed here, so the comparison was verified against a model of
the .NET `[version]` semantics rather than PowerShell itself. The Windows CI leg
is the real check.

Three mutations, three caught, including restoring the string comparison.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 23:50:57 +01:00
Mervin Praison be80d196b7 Merge pull request #4585 from MervinPraison/fix/mobile-sweep8-tier2
fix(mobile): the SSE one-space rule, and focus landing on a dead button
2026-08-28 23:46:12 +01:00
MervinPraisonandClaude Opus 5 7afa86d3c1 fix(mobile): the SSE one-space rule, and focus landing on a dead button
Five more from the eighth sweep, in the protocol and accessibility paths.

  sse.ts   `value.slice(1)` -> `value.trimStart()`
      The specification strips EXACTLY one leading space. Trimming corrupts any
      payload that legitimately begins with whitespace -- indented JSON, or
      text whose first token is deliberately padded -- and nothing reports it.
      The comment above the line says exactly this; nothing checked it.

  sse.ts   `field === "retry"` dropped from the recognised set
      A retry-only frame returns null and DISAPPEARS from the stream, rather
      than being read and deliberately discarded. Paired with a block that has
      no recognised field at all, which must still not become a frame.

  decode.ts   `seconds === undefined ? null : seconds` -> `? 0 :`
      A tool the engine never timed renders "0.0s" -- a measurement presented
      as fact. `durationKnown` exists precisely to keep those apart, and its
      pair is here too: a MEASURED zero is not unknown.

  a11y/focus.ts   the survivor search stopped excluding the disabled set
      A screen-reader user answering an approval lands on the dead Allow button
      they just pressed. VoiceOver drops focus on a disabled element back to
      the top of the screen, so they lose their place in the conversation
      entirely. Paired with the rule the function's header states: focus the
      user did not ask to move must not move, or an approval resolving in the
      background steals the caret out of the composer.

  web/shell.ts   `pushState` -> `replaceState` after a consumed back gesture
      The browser entry is replaced rather than restored, so the NEXT back
      press exits the app instead of going one screen up. Paired with the
      declined case, because pushing unconditionally traps the user on the page
      -- the OS back gesture could then never leave the app.

1149 tests on node 22.23 and 24.12. Five mutations confirmed to fail.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 23:41:55 +01:00
Mervin Praison 72bdc4b2a2 Merge pull request #4584 from MervinPraison/fix/mobile-sweep8-tier1
fix(mobile): a Stop that could never match, and a link that opened nothing
2026-08-28 23:37:41 +01:00
MervinPraisonandClaude Opus 5 cdeaccaaa6 fix(mobile): a Stop that could never match, and a link that opened nothing
Eleven more survivors from the eighth package-wide sweep, all user-facing.

  remote-http   `run_id: request.runId` -> `request.chatId`
      `POST /cancel/{runId}` can then never match a live run, so Stop is
      PERMANENTLY dead against the remote engine -- which keeps generating and
      keeps billing. The two ids are the same shape, so nothing about the
      payload looks wrong.

  tauri/bridge  three independent defects in one line of `webOpen`
      drop `open(url)` and keep `return true`  -- reports success having opened
          nothing, so a link does nothing and the app believes it worked;
      `"_blank"` -> `"_self"`                  -- the link REPLACES the app;
      `"noopener,noreferrer"` -> `""`          -- the opened page keeps a live
          `window.opener` back into the app, and the referrer leaks.
      Paired: openExternal must report FALSE when there is nothing to open
      with, or every real link looks broken instead.

  transcript    `tool_result` wiping the args its `tool_call` recorded
      A finished tool can no longer say what it ran -- and args are what an
      approval row shows the user before they allow it. Paired with the
      orphan-result case, which is the branch that SHOULD yield {}.

  transcript    reasoning assigned rather than accumulated
      Only the last chunk is ever shown, so visible thinking truncates to its
      final fragment.

  web/storage   `PREFIX = "praisonai."` -> `""`
      Keys become `chats.c1` on a shared origin: they collide with anything
      else stored there, and every existing install's data becomes unreachable.
      The port contract cannot see this -- it reads back through the same
      adapter, which is self-consistent either way.

  settings      notify iterating the LIVE subscriber set
      A subscriber that unsubscribes another during notify makes the second one
      never fire. A settings screen tearing down one row silently drops
      another's update.

  classify      `status === 403` dropped
      The message branch only catches text literally containing "403" or
      "forbidden", so `{status: 403, message: "nope"}` classifies as `internal`
      and the user gets no route to their credentials -- the whole reason
      ErrorKind separates auth from everything else.

  remote-http   accept header, and `/\/+$/` -> `/\/$/`
      The stream stops advertising SSE; and an address with two trailing
      slashes builds `//chat`, which 404s with nothing explaining why.

1139 tests on node 22.23 and 24.12. Eleven mutations confirmed to fail.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 23:33:14 +01:00
Mervin Praison dea9ef827d Merge pull request #4583 from MervinPraison/fix/mobile-gate-and-scheme
fix(mobile): the boundary gate had no test, and I had already broken it
2026-08-28 23:28:18 +01:00
MervinPraisonandClaude Opus 5 e5b6aafb57 fix(mobile): the boundary gate had no test, and I had already broken it
An eighth package-wide sweep put `tools/` at 46.7% genuine survival -- the
weakest layer in the package, and the one holding the gate every layering
claim depends on. `check-boundaries.mjs` had no test of any kind.

Two independent one-character edits turned the whole gate into a no-op with
`npm run check` still green:

  `files.length === 0` -> `>= 0`   takes the "nothing to check" branch every
                                   time, reporting success having looked at
                                   nothing at all;
  `process.exit(1)`    -> `exit(0)` prints every violation it found and passes.

depgraph.test.mjs covers the RULE thoroughly. Nothing covered the CLI that
decides what to walk and turns the answer into an exit code -- and the exit
code is the only part CI reads.

AND THE TEST IMMEDIATELY FOUND A REGRESSION OF MINE

The ungoverned-root check was gone. Not mutated: GONE, from `main`, since the
commit where I extracted `sourceFiles` into depgraph.mjs to make it testable.
The import survived and the call did not, because my slice ran from the
function's doc comment to its call site and the ungoverned block sat between
them.

So for six commits, a new top-level directory could import across both seams
and the checker said nothing -- which is exactly the defect that check was
written to prevent, reintroduced by the refactor meant to make it testable, in
a file with no test. It is restored, and the fourth test in the new file is
what caught it.

The empty-set branch now EXITS 1. Its comment always said "Not a pass"; the
exit code said otherwise, and that disagreement is what made the gate
switchable off by one character. The root is also injectable now
(`node check-boundaries.mjs [root]`), which is what lets the CLI be driven
against fixture trees at all.

ALSO: THE SCHEME GUARD WAS ONLY TESTED THROUGH ITS CALLERS

`isOpenableExternally` had no test at its own boundary. Dropping the `^` from
its regex survived: any string CONTAINING a permitted scheme anywhere then
passes, so `/redirect?to=https://evil.example` and `./rel?x=mailto:a@b` both
return true. Both shells delegate to this before handing a URL to the OS.

Pinned in both directions -- a guard that refused anything containing "https"
anywhere would pass the new cases and break every real link, so
`https://ok.example/path?next=https://other` must still be allowed.

1128 tests on node 22.23 and 24.12. Four mutations confirmed to fail.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 23:24:54 +01:00
252 changed files with 37264 additions and 2477 deletions

No files matched your search

+23 -4
View File
@@ -140,13 +140,32 @@ jobs:
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }}
run: |
$expected = $env:RELEASE_TAG -replace '^v', ''
$numeric = ($expected -split '[-+]')[0] + '.0'
$version = (Get-Item "target/${{ matrix.target }}/release/praisonai-desktop.exe").VersionInfo
$core = ($expected -split '[-+]')[0]
$exe = "target/${{ matrix.target }}/release/praisonai-desktop.exe"
$version = (Get-Item $exe).VersionInfo
Write-Host "ProductVersion=$($version.ProductVersion) FileVersion=$($version.FileVersion)"
if ($version.ProductVersion -ne $expected) {
throw "ProductVersion $($version.ProductVersion) does not match $expected"
}
if ($version.FileVersion -ne $numeric) {
throw "FileVersion $($version.FileVersion) does not match $numeric"
# Compare the three components that carry meaning, not the string.
#
# This gate blocked the v4.7.4 Windows build with
# FileVersion 4.7.4 does not match 4.7.4.0
# It appended ".0" and compared literally, but Windows reports what
# was stamped -- three parts here. The stamping was correct and the
# check was not, so the release shipped with no Windows installer at
# all: the one platform it was cut to fix.
#
# [version] normalises a missing fourth component to -1, so both
# "4.7.4" and "4.7.4.0" compare equal on major.minor.build.
$got = [version]$version.FileVersion
$want = [version]$core
if ($got.Major -ne $want.Major -or
$got.Minor -ne $want.Minor -or
$got.Build -ne $want.Build) {
throw "FileVersion $($version.FileVersion) does not match $core"
}
- name: The macOS bundle must carry a valid signature
+66 -2
View File
@@ -26,9 +26,18 @@ name: mobile
on:
push:
branches: [main]
paths: ['src/praisonai-mobile/**', '.github/workflows/mobile.yml']
paths: &watched
- 'src/praisonai-mobile/**'
- '.github/workflows/mobile.yml'
# The `upstream parity` job below typechecks the REAL praisonai-ts Agent
# against the structural contract in engines/src/praisonai-ts/agent-api.ts.
# Watching only praisonai-mobile made that gate fire when the CONSUMER
# changed and stay silent when the thing it watches did -- so upstream
# drift landed on main unchecked. Verified on 71197fa80, which edited
# src/praisonai-ts/src/agent/simple.ts: no parity check ran on it.
- 'src/praisonai-ts/src/**'
pull_request:
paths: ['src/praisonai-mobile/**', '.github/workflows/mobile.yml']
paths: *watched
concurrency:
group: mobile-${{ github.ref }}
@@ -146,3 +155,58 @@ jobs:
run: cargo test
- name: Clippy
run: cargo clippy --all-targets -- -D warnings
# ------------------------------------------------------------------
# The mobile targets, which nothing compiled.
#
# `src/lib.rs` is `#[cfg_attr(mobile, tauri::mobile_entry_point)]`, and
# `tauri_build::build()` emits `cargo:rustc-cfg=mobile` only for android and
# ios targets. Both legs of `rust shell` above are HOST targets, so that
# attribute -- the one that generates the Android JNI symbol and the iOS
# start_app symbol -- expanded exactly zero times in CI, and neither did the
# `#[cfg(target_os = "android")]` / `ios` arms of `commands.rs`. The job
# above says it covers cfg mistakes; it covers the ones in `shell/`, not
# these.
#
# `cargo check`, not `build`: it type-checks and macro-expands under the
# mobile cfg, which is the thing that was never done, and needs no NDK and no
# Xcode project. Verified at 18s for the android target.
# ------------------------------------------------------------------
cross-check:
name: cross-check (${{ matrix.target }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-linux-android
os: ubuntu-22.04
- target: aarch64-apple-ios
os: macos-15
defaults:
run:
working-directory: src/praisonai-mobile/src-tauri
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22.18'
cache: npm
cache-dependency-path: src/praisonai-mobile/package-lock.json
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: src/praisonai-mobile/src-tauri
key: ${{ matrix.target }}
# generate_context! expands frontendDist at COMPILE time and dist/ is
# gitignored, so the crate cannot be checked without this -- the same
# reason the job above needs it.
- name: Build the webview (creates ../dist for generate_context!)
working-directory: src/praisonai-mobile
run: |
npm ci
npm run build
- name: Does the crate still expand and type-check for a phone?
run: cargo check --lib --target ${{ matrix.target }}
+79
View File
@@ -0,0 +1,79 @@
name: Parity Gate
# Validates Python <-> TypeScript SDK parity on pull requests and FAILS on drift.
#
# Two named checks, so each kind of drift is visible in the checks list:
# names - the exported-name tracker (PARITY.md / FEATURE_PARITY_TRACKER.json)
# must be regenerated in the PR when the public surface changes.
# signatures - constructor/method parameters of the curated surfaces in
# src/praisonai/praisonai/_dev/parity/signatures/surface.yaml must
# match (after camelCase/alias/flattening rules) or be waived with a
# reason in waivers.yaml. Stale or expired waivers also fail.
#
# The regenerate-and-commit job stays in update-parity-tracker.yml (push to main).
# This workflow never commits; it only reports.
on:
pull_request:
paths:
- 'src/praisonai-agents/praisonaiagents/**/*.py'
- 'src/praisonai-ts/src/**/*.ts'
- 'src/praisonai-rust/praisonai/src/**/*.rs'
- 'src/praisonai/praisonai/_dev/parity/**'
- 'src/praisonai/praisonai/cli/features/**'
- 'src/praisonai-ts/PARITY.md'
- 'src/praisonai-ts/FEATURE_PARITY_TRACKER.json'
- 'src/praisonai-ts/SIGNATURE_PARITY.md'
- 'src/praisonai-ts/signature-parity.json'
- 'src/praisonai-rust/PARITY.md'
- 'src/praisonai-rust/FEATURE_PARITY_TRACKER.json'
- '.github/workflows/parity-gate.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
names:
name: names (exported-symbol tracker up to date)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install pyyaml
- name: Tracker files match the sources
run: |
if ! python3 src/praisonai/scripts/generate_parity_tracker.py --check; then
echo "::error::Parity tracker is out of date. Run: python3 src/praisonai/scripts/generate_parity_tracker.py and commit src/praisonai-ts/PARITY.md, FEATURE_PARITY_TRACKER.json and the Rust pair."
exit 1
fi
signatures:
name: signatures (constructor and method parameters)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: pip install pyyaml
- name: Install the TypeScript compiler only
# praisonai-ts's full dependency tree is not needed (and does not
# install cleanly); the extractor needs only the `typescript` package.
run: |
mkdir -p "$RUNNER_TEMP/parity-ts"
npm install --prefix "$RUNNER_TEMP/parity-ts" --no-audit --no-fund [email protected]
echo "PARITY_TS_NODE_MODULES=$RUNNER_TEMP/parity-ts/node_modules" >> "$GITHUB_ENV"
- name: Signatures match, or are waived with a reason
env:
PYTHONPATH: src/praisonai
run: |
if ! python3 -m praisonai._dev.parity.signatures --check; then
echo "::error::Signature parity failed. See the output above for the surface and parameter. Fix the TypeScript side, or add a waiver with a reason to src/praisonai/praisonai/_dev/parity/signatures/waivers.yaml, then run: PYTHONPATH=src/praisonai python3 -m praisonai._dev.parity.signatures --write"
exit 1
fi
+5 -1
View File
@@ -6,7 +6,11 @@ on:
paths:
- 'src/praisonai-agents/praisonaiagents/**/*.py'
- 'src/praisonai-ts/src/**/*.ts'
- 'src/praisonai-rust/src/**/*.rs'
# The crate lives at src/praisonai-rust/praisonai/src -- the old
# 'src/praisonai-rust/src/**' path does not exist, so a Rust change
# never triggered either tracker and the Rust numbers only ever
# refreshed incidentally, on an unrelated push.
- 'src/praisonai-rust/praisonai/src/**/*.rs'
- 'src/praisonai/praisonai/_dev/parity/**/*.py'
- '.github/workflows/update-docs-parity.yml'
workflow_dispatch: # Allow manual trigger
+18 -5
View File
@@ -1,21 +1,35 @@
name: Update Feature Parity Tracker
# Regenerates the exported-name parity trackers on push to main and commits them.
# Validation on pull requests (fail on drift) lives in parity-gate.yml.
on:
push:
branches: [ "main" ]
paths:
- 'src/praisonai-agents/praisonaiagents/**/*.py'
- 'src/praisonai-ts/src/**/*.ts'
- 'src/praisonai-rust/src/**/*.rs'
# The crate lives at src/praisonai-rust/praisonai/src -- the old
# 'src/praisonai-rust/src/**' path does not exist, so a Rust change
# never triggered either tracker and the Rust numbers only ever
# refreshed incidentally, on an unrelated push.
- 'src/praisonai-rust/praisonai/src/**/*.rs'
# The generator's own code and the wrapper features it counts. Change
# either and nothing regenerated until an unrelated push landed.
- 'src/praisonai/praisonai/_dev/parity/**/*.py'
- 'src/praisonai/praisonai/cli/features/**/*.py'
- '.github/workflows/update-parity-tracker.yml'
permissions:
contents: write
jobs:
update-parity-tracker:
# Push only. On a PR the parity-gate.yml `names` job CHECKS instead --
# committing a regenerated tracker onto a contributor's branch is not this
# workflow's business, and would fail on a fork besides.
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -36,7 +50,7 @@ jobs:
run: |
cd $GITHUB_WORKSPACE
python3 src/praisonai/scripts/generate_parity_tracker.py
- name: Check for changes
id: check_changes
run: |
@@ -58,4 +72,3 @@ jobs:
commit_user_name: "MervinPraison"
commit_user_email: "[email protected]"
commit_author: "MervinPraison <[email protected]>"
+1 -1
View File
@@ -781,7 +781,7 @@ Methods:
Types:
```python
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, DB, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, __version__, arun, run
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, __version__, arun, run
```
# CLI
+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.4" \
"praisonai>=4.7.5" \
"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.4" \
"praisonai>=4.7.5" \
"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.4" \
"praisonai>=4.7.5" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -0,0 +1,73 @@
"""
Meeting Notes Agent
An AI meeting-notes pipeline composed entirely from existing PraisonAI
primitives — no new SDK surface required:
1. AudioAgent.transcribe() -> Whisper speech-to-text
2. Agent (summary) -> structured summary + action items
3. Agent (knowledge=[...]) -> index transcript for cross-meeting Q&A
Usage:
python meeting-notes-agent.py path/to/recording.mp3
Requires: OPENAI_API_KEY (or any LiteLLM-supported STT/LLM provider).
"""
import sys
from praisonaiagents import Agent, AudioAgent
def transcribe_meeting(audio_path: str) -> str:
"""Speech-to-text using the built-in Whisper AudioAgent."""
stt = AudioAgent(llm="openai/whisper-1")
return stt.transcribe(audio_path)
def summarize_meeting(transcript: str) -> str:
"""Summarize decisions, topics, and action items."""
notetaker = Agent(
name="Meeting Notetaker",
instructions=(
"You are an expert meeting notetaker. Given a raw transcript, produce:\n"
"1. A concise summary (3-5 sentences).\n"
"2. Key decisions made.\n"
"3. Action items as a checklist with owners when mentioned.\n"
"Only include action items that were actually spoken."
),
)
return notetaker.start(f"Transcript:\n\n{transcript}")
def build_qa_agent(transcript_path: str) -> Agent:
"""Index the transcript for semantic search and cross-meeting Q&A."""
return Agent(
name="Meeting Q&A",
instructions="Answer questions using only the indexed meeting transcripts. Cite what was said.",
knowledge=[transcript_path],
)
def main() -> None:
if len(sys.argv) < 2:
print("Usage: python meeting-notes-agent.py <audio_file>")
sys.exit(1)
audio_path = sys.argv[1]
transcript = transcribe_meeting(audio_path)
transcript_path = "meeting_transcript.txt"
with open(transcript_path, "w", encoding="utf-8") as f:
f.write(transcript)
notes = summarize_meeting(transcript)
print("\n=== Meeting Notes ===\n")
print(notes)
qa = build_qa_agent(transcript_path)
print("\n=== Q&A ===\n")
print(qa.start("What were the main action items and who owns them?"))
if __name__ == "__main__":
main()
+22 -22
View File
@@ -18,41 +18,41 @@ This report compares **Python SDK feature categories** against **Python document
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 511 |
| ✅ Agent | 22 | 54 | 17350 |
| ✅ Agent | 22 | 56 | 17728 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 689 |
| ✅ Approval | 1 | 7 | 3311 |
| ✅ Approval | 1 | 7 | 3503 |
| ✅ Audio | 2 | 12 | 1000 |
| ✅ Auto Generation | 5 | 12 | 3747 |
| ✅ Autonomy | 3 | 7 | 2790 |
| ✅ Bots | 7 | 32 | 13622 |
| ✅ Bots | 7 | 33 | 13860 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 122 | 37626 |
| ✅ CLI | 5 | 123 | 37946 |
| ✅ Chunking | 2 | 2 | 420 |
| ✅ Citations | 2 | 1 | 202 |
| ✅ Code Execution | 2 | 14 | 4855 |
| ✅ Conditions | 1 | 3 | 1347 |
| ✅ Configuration | 3 | 8 | 3292 |
| ✅ Context Management | 16 | 36 | 14071 |
| ✅ Context Management | 16 | 36 | 14090 |
| ✅ Deep Research | 8 | 2 | 587 |
| ✅ Display | 6 | 3 | 741 |
| ✅ Embeddings | 6 | 23 | 2049 |
| ✅ Evaluation | 1 | 7 | 2920 |
| ✅ Evaluation | 1 | 8 | 3220 |
| ✅ Events | 1 | 2 | 767 |
| ✅ Execution | 3 | 4 | 1606 |
| ✅ Execution | 3 | 4 | 1631 |
| ✅ Failover | 2 | 1 | 452 |
| ✅ Files | 2 | 7 | 2275 |
| ✅ Files | 2 | 7 | 2376 |
| ✅ Flow | 1 | 3 | 788 |
| ✅ Gateway | 7 | 83 | 30723 |
| ✅ Guardrails | 4 | 4 | 2253 |
| ✅ Gateway | 7 | 85 | 31517 |
| ✅ Guardrails | 4 | 4 | 2288 |
| ✅ Handoffs | 11 | 6 | 2626 |
| ✅ Hooks | 2 | 9 | 4542 |
| ✅ Hooks | 2 | 9 | 4690 |
| ✅ Image | 1 | 11 | 1255 |
| ✅ Knowledge | 4 | 17 | 5226 |
| ✅ LLM | 3 | 15 | 5395 |
| ✅ Loops | 4 | 5 | 1475 |
| ✅ MCP | 1 | 62 | 14418 |
| ✅ Memory | 6 | 18 | 7237 |
| ✅ MCP | 1 | 62 | 14436 |
| ✅ Memory | 6 | 19 | 7512 |
| ✅ OCR | 2 | 1 | 237 |
| ✅ Observability | 2 | 23 | 2811 |
| ✅ Optimizer | 1 | 2 | 792 |
@@ -60,8 +60,8 @@ This report compares **Python SDK feature categories** against **Python document
| ✅ Parallel Execution | 3 | 2 | 497 |
| ✅ Planning | 6 | 7 | 1825 |
| ✅ Plugins | 8 | 5 | 3120 |
| ✅ Prompts | 2 | 9 | 1971 |
| ✅ Providers | 1 | 56 | 7793 |
| ✅ Prompts | 2 | 9 | 2011 |
| ✅ Providers | 1 | 57 | 8197 |
| ✅ Query | 1 | 2 | 736 |
| ✅ RAG | 5 | 15 | 3118 |
| ✅ Realtime | 2 | 5 | 668 |
@@ -70,24 +70,24 @@ This report compares **Python SDK feature categories** against **Python document
| ✅ Routing | 1 | 2 | 430 |
| ✅ Sandbox | 5 | 7 | 3556 |
| ✅ Security | 1 | 3 | 3529 |
| ✅ Sessions | 4 | 19 | 7644 |
| ✅ Skills | 6 | 16 | 5903 |
| ✅ Sessions | 4 | 19 | 7648 |
| ✅ Skills | 6 | 16 | 5911 |
| ✅ Tasks | 2 | 6 | 2897 |
| ✅ Telemetry | 1 | 2 | 648 |
| ✅ Templates | 1 | 8 | 1663 |
| ✅ Tools | 12 | 145 | 43981 |
| ✅ Templates | 1 | 8 | 1667 |
| ✅ Tools | 12 | 145 | 44115 |
| ✅ Tracing | 3 | 2 | 139 |
| ✅ Vector Store | 1 | 12 | 1219 |
| ✅ Video | 2 | 6 | 649 |
| ✅ Video | 2 | 6 | 728 |
| ✅ Vision | 2 | 1 | 329 |
| ✅ Web | 3 | 9 | 2487 |
| ✅ Workflows | 5 | 18 | 7406 |
| ✅ Workflows | 5 | 18 | 7410 |
## Documentation Without Features
These docs exist but don't match any implemented feature category:
- ℹ️ Database (43 docs, 7130 lines)
- ℹ️ Database (43 docs, 7293 lines)
- ℹ️ Documents (1 docs, 798 lines)
---
@@ -1,4 +1,5 @@
import os
import re
import time
import json
import logging
@@ -8,6 +9,7 @@ import contextlib
import threading
import concurrent.futures
import random
import re
from typing import List, Optional, Any, Dict, Union, Literal, TYPE_CHECKING, Callable, Generator
from collections import OrderedDict
import inspect
@@ -220,6 +222,22 @@ from ..errors import BudgetExceededError
# Import retry configuration
from .retry_utils import RetryBackoffConfig
# Completion-signal detection patterns (compiled once at import — see
# Agent._is_completion_signal). Negation patterns that should NOT be treated
# as completion.
_COMPLETION_NEGATION_RE = re.compile(
r'\b(?:not|never|no longer|hardly|barely|isn\'t|aren\'t|wasn\'t|weren\'t|hasn\'t|haven\'t|hadn\'t|won\'t|wouldn\'t|can\'t|couldn\'t|shouldn\'t|don\'t|doesn\'t|didn\'t)\b'
r'.{0,20}' # up to 20 chars between negation and keyword
)
# Word-boundary patterns to avoid substring false positives
_COMPLETION_PATTERNS = (
(re.compile(r'\btask\s+completed?\b'), False), # no negation check needed
(re.compile(r'\bcompleted\s+successfully\b'), False),
(re.compile(r'\ball\s+done\b'), False),
(re.compile(r'\bdone\b'), True), # 'done' needs negation check
(re.compile(r'\bfinished\b'), True), # 'finished' needs negation check
)
class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
# Class-level counter for generating unique display names for nameless agents
_agent_counter = 0
@@ -3186,9 +3204,21 @@ Your Goal: {self.goal}
return self.__chat_history_state
def _append_to_chat_history(self, message: dict):
"""Thread-safe append to chat history using proper locking."""
"""Thread-safe append to chat history using proper locking.
Also records the appended message into the current turn's per-turn
ownership tracker (when one is active) so a failing turn's rollback
removes only its own messages by identity and never a concurrent
turn's. This is the append path every real chat()/achat() turn uses,
so without recording here _rollback_chat_history_to would always take
the unsafe positional-delete branch.
"""
from .memory_mixin import _active_turn_owned
with self._history_lock.lock():
self._history_lock.value.append(message)
owned = _active_turn_owned.get()
if owned is not None:
owned.append(message)
def _replace_chat_history(self, new_history: List[Dict[str, Any]]):
"""Thread-safe replacement of entire chat history using proper locking."""
@@ -4179,21 +4209,7 @@ Summary:"""
Returns:
True if a completion signal is detected
"""
import re
response_lower = response_text.lower()
# Negation patterns that should NOT be treated as completion
_NEGATION_RE = re.compile(
r'\b(?:not|never|no longer|hardly|barely|isn\'t|aren\'t|wasn\'t|weren\'t|hasn\'t|haven\'t|hadn\'t|won\'t|wouldn\'t|can\'t|couldn\'t|shouldn\'t|don\'t|doesn\'t|didn\'t)\b'
r'.{0,20}' # up to 20 chars between negation and keyword
)
# Word-boundary patterns to avoid substring false positives
_COMPLETION_PATTERNS = [
(re.compile(r'\btask\s+completed?\b'), False), # no negation check needed
(re.compile(r'\bcompleted\s+successfully\b'), False),
(re.compile(r'\ball\s+done\b'), False),
(re.compile(r'\bdone\b'), True), # 'done' needs negation check
(re.compile(r'\bfinished\b'), True), # 'finished' needs negation check
]
for pattern, needs_negation_check in _COMPLETION_PATTERNS:
match = pattern.search(response_lower)
if match:
@@ -4201,7 +4217,7 @@ Summary:"""
# Check if a negation word precedes the match within 30 chars
start = max(0, match.start() - 30)
prefix = response_lower[start:match.start()]
if _NEGATION_RE.search(prefix):
if _COMPLETION_NEGATION_RE.search(prefix):
continue # Skip — negated completion
# Also check "not X yet" pattern
end = min(len(response_lower), match.end() + 10)
@@ -1638,6 +1638,47 @@ Your Goal: {self.goal}"""
return None
return self.fallback_models[fallback_index]
def _build_fallback_dispatcher(self, model):
"""Build a throwaway dispatcher bound to a fallback ``model``.
The async fallback path passes the result to
``_execute_unified_achat_completion(..., _dispatcher=...)`` for a single
retry so it NEVER mutates the shared ``self.llm`` /
``self._unified_dispatcher``. Concurrent async turns on the same Agent
therefore keep dispatching on the original model with correct cost /
observability attribution — closing the ordinary-turn fallback race.
Returns ``None`` for custom-LLM agents (the custom client owns its own
model), which keeps that path on the shared dispatcher as before.
"""
from ..llm import create_llm_dispatcher
if self._using_custom_llm and hasattr(self, 'llm_instance'):
return None
if not hasattr(self, '_openai_client') or self._openai_client is None:
from ..llm import get_openai_client
self._openai_client = get_openai_client(api_key=getattr(self, 'api_key', None))
return create_llm_dispatcher(openai_client=self._openai_client, model=model)
def _get_model_fallback_lock(self):
"""Lazily create the per-instance lock guarding model-fallback swaps.
Async fallback now runs against a per-call throwaway dispatcher
(``_build_fallback_dispatcher``), so it does not mutate shared state and
ordinary async turns can never observe the fallback model. The sync
fallback path still recurses through the full ``_chat_completion``
wrapper (which reads ``self.llm`` for BEFORE_LLM hook attribution and
budget), so it temporarily overrides ``self.llm`` /
``self._unified_dispatcher`` for a single retry. This dual (sync/async)
lock serialises that mutate/restore window; normal, non-fallback turns
never acquire it and stay fully concurrent. Created on demand so no
extra state is added for agents without ``fallback_models``.
"""
lock = getattr(self, '_model_fallback_lock', None)
if lock is None:
from .async_safety import DualLock
lock = DualLock()
self._model_fallback_lock = lock
return lock
def _emit_model_fallback(self, from_model, to_model, reason_category, fallback_index,
stream_callback=None):
"""Surface a mid-turn model fallback as an observable state transition.
@@ -2094,22 +2135,26 @@ Your Goal: {self.goal}"""
if classification.backoff_seconds and classification.backoff_seconds > 0:
time.sleep(classification.backoff_seconds)
# Temporarily override the model and clear dispatcher cache for this call
original_llm = self.llm
original_dispatcher = getattr(self, '_unified_dispatcher', None)
try:
self.llm = next_model
self._unified_dispatcher = None # Force recreation with new model
return self._chat_completion(
messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
_retry_depth=_retry_depth + 1,
_fallback_index=_fallback_index + 1,
cancel_token=cancel_token
)
finally:
self.llm = original_llm
self._unified_dispatcher = original_dispatcher # Restore original dispatcher
# Temporarily override the model and clear dispatcher cache
# for this call. Serialise the mutate/restore window so a
# concurrent turn on a shared Agent can't observe the wrong
# model; only fallback turns contend on this lock.
with self._get_model_fallback_lock().sync():
original_llm = self.llm
original_dispatcher = getattr(self, '_unified_dispatcher', None)
try:
self.llm = next_model
self._unified_dispatcher = None # Force recreation with new model
return self._chat_completion(
messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
_retry_depth=_retry_depth + 1,
_fallback_index=_fallback_index + 1,
cancel_token=cancel_token
)
finally:
self.llm = original_llm
self._unified_dispatcher = original_dispatcher # Restore original dispatcher
else:
logging.warning(f"[{self.name}] {classification.user_message} (no more fallback models available)")
# Continue to error handling without retry
@@ -2272,16 +2317,21 @@ Your Goal: {self.goal}"""
if classification.backoff_seconds and classification.backoff_seconds > 0:
await asyncio.sleep(classification.backoff_seconds)
# Temporarily override the model and clear dispatcher cache for this call
original_llm = self.llm
original_dispatcher = getattr(self, '_unified_dispatcher', None)
# Run this single retry against a throwaway dispatcher bound to
# the fallback model, passed per-call. This never mutates the
# shared self.llm / self._unified_dispatcher, so a concurrent
# async turn on this shared Agent keeps dispatching on the
# original model (correct cost / observability attribution) even
# while this turn is suspended on the await — no lock needed on
# the async path. Custom-LLM agents get None here and fall back
# to the shared dispatcher, matching prior behaviour.
_fallback_dispatcher = self._build_fallback_dispatcher(next_model)
try:
self.llm = next_model
self._unified_dispatcher = None # Force recreation with new model
return await self._execute_unified_achat_completion(
messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
stream_callback, emit_events
stream_callback, emit_events,
_dispatcher=_fallback_dispatcher,
)
except Exception as retry_error:
return await self._handle_async_llm_error(
@@ -2289,9 +2339,6 @@ Your Goal: {self.goal}"""
reasoning_steps, task_name, task_description, task_id, response_format,
stream_callback, emit_events, _retry_depth + 1, _fallback_index + 1
)
finally:
self.llm = original_llm
self._unified_dispatcher = original_dispatcher # Restore original dispatcher
else:
logging.warning(f"[{self.name}] {classification.user_message} (no more fallback models available)")
# Continue to error handling without retry
@@ -2499,29 +2546,43 @@ Your Goal: {self.goal}"""
response_format=None,
stream_callback=None,
emit_events=True,
_dispatcher=None,
):
"""
Execute unified async chat completion using composition instead of runtime class mutation.
This method provides a unified async dispatch for all LLM providers using
composition for safety and maintainability.
``_dispatcher`` lets the model-fallback path run a single retry against a
caller-supplied throwaway dispatcher WITHOUT mutating the shared
``self.llm`` / ``self._unified_dispatcher``, so concurrent async turns on
the same Agent never observe another turn's fallback model.
"""
from ..llm import create_llm_dispatcher
# Get or create unified dispatcher
if not hasattr(self, '_unified_dispatcher') or self._unified_dispatcher is None:
# Provider selection based on existing agent configuration
if self._using_custom_llm and hasattr(self, 'llm_instance'):
dispatcher = create_llm_dispatcher(llm_instance=self.llm_instance)
# Resolve the dispatcher for THIS call. A caller-supplied dispatcher
# (async model fallback) is used as-is and never cached onto the
# instance, so the shared cached dispatcher is left intact for
# concurrent turns.
dispatcher = _dispatcher
if dispatcher is None:
# Get or create the shared cached dispatcher
if not hasattr(self, '_unified_dispatcher') or self._unified_dispatcher is None:
# Provider selection based on existing agent configuration
if self._using_custom_llm and hasattr(self, 'llm_instance'):
dispatcher = create_llm_dispatcher(llm_instance=self.llm_instance)
else:
# Initialize OpenAI client if not present
if not hasattr(self, '_openai_client') or self._openai_client is None:
from ..llm import get_openai_client
self._openai_client = get_openai_client(api_key=getattr(self, 'api_key', None))
dispatcher = create_llm_dispatcher(openai_client=self._openai_client, model=self.llm)
# Cache the dispatcher
self._unified_dispatcher = dispatcher
else:
# Initialize OpenAI client if not present
if not hasattr(self, '_openai_client') or self._openai_client is None:
from ..llm import get_openai_client
self._openai_client = get_openai_client(api_key=getattr(self, 'api_key', None))
dispatcher = create_llm_dispatcher(openai_client=self._openai_client, model=self.llm)
# Cache the dispatcher
self._unified_dispatcher = dispatcher
dispatcher = self._unified_dispatcher
# Pre-call budget guard (parity with sync _chat_completion). Bots and
# other async callers route through here, not _chat_completion.
@@ -2577,7 +2638,7 @@ Your Goal: {self.goal}"""
try:
if stream_callback is None and hasattr(self, 'stream_emitter'):
stream_callback = getattr(self.stream_emitter, 'emit', None)
final_response = await self._unified_dispatcher.achat_completion(
final_response = await dispatcher.achat_completion(
messages=messages,
tools=tools,
tool_choice=getattr(self, 'tool_choice', None),
@@ -4856,7 +4917,21 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
try:
# Use the new streaming generator from LLM class
response_content = ""
stream_history = self.chat_history
system_prompt_for_stream = self._build_system_prompt(
tool_param,
memory_prefetch_context=memory_prefetch_context,
)
# Apply context management (auto-compaction) to the history
# that is actually sent, the same way the non-streaming
# custom-LLM path does. Without this a configured
# ContextManager -- auto-enabled whenever tools are present
# -- is silently skipped on stream=True (Issue #4714).
# Zero overhead when context=False.
stream_history, _stream_context_result = self._apply_context_management(
messages=self.chat_history,
system_prompt=system_prompt_for_stream,
tools=tool_param,
)
durable_context = self._get_durable_run_context()
if durable_context is not None:
stream_history = durable_context.restore_messages(
@@ -4864,10 +4939,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
)
for chunk in self.llm_instance.get_response_stream(
prompt=actual_prompt,
system_prompt=self._build_system_prompt(
tool_param,
memory_prefetch_context=memory_prefetch_context,
),
system_prompt=system_prompt_for_stream,
chat_history=stream_history,
temperature=kwargs.get('temperature', 1.0),
tools=tool_param,
@@ -4945,6 +5017,18 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self.chat_history[-1].get("content") == normalized_content):
self._append_to_chat_history({"role": "user", "content": normalized_content})
# Apply context management before the LLM call (auto-compaction),
# exactly as the non-streaming OpenAI path does. Without this a
# configured ContextManager -- auto-enabled whenever tools are
# present -- is silently skipped on stream=True and the full
# history is sent (Issue #4714). Zero overhead when context=False.
system_prompt_content = messages[0].get("content", "") if messages and messages[0].get("role") == "system" else ""
messages, _stream_context_result = self._apply_context_management(
messages=messages,
system_prompt=system_prompt_content,
tools=tool_param,
)
try:
# Check if OpenAI client is available
if self._openai_client is None:
@@ -802,21 +802,31 @@ Write the complete compiled report:"""
Args:
new_model: The new model name to switch to (e.g., "gpt-4o", "claude-3-sonnet")
"""
# Store the new model name
self.llm = new_model
# Recreate the LLM instance with the new model
try:
from ..llm.llm import LLM
self._llm_instance = LLM(
model=new_model,
base_url=self._openai_base_url,
api_key=self._openai_api_key,
)
self._using_custom_llm = True
except ImportError:
# If LLM class not available, just update the model string
pass
# Serialise against an in-flight sync model fallback (which also swaps
# self.llm / self._unified_dispatcher) so the two can't interleave.
with self._get_model_fallback_lock().sync():
# Store the new model name
self.llm = new_model
# Drop any cached dispatcher bound to the old model so the next
# chat()/achat() rebuilds against new_model. Without this, the
# OpenAI path keeps dispatching on the old model even though
# self.llm now reads the new name (mirrors _apply_default_llm).
if getattr(self, '_unified_dispatcher', None) is not None:
self._unified_dispatcher = None
# Recreate the LLM instance with the new model
try:
from ..llm.llm import LLM
self._llm_instance = LLM(
model=new_model,
base_url=self._openai_base_url,
api_key=self._openai_api_key,
)
self._using_custom_llm = True
except ImportError:
# If LLM class not available, just update the model string
pass
def start(self, prompt: Optional[str] = None, **kwargs: Any) -> Union[str, Generator[str, None, None], None]:
"""Start the agent interactively with verbose output.
@@ -1702,6 +1712,27 @@ Write the complete compiled report:"""
return policy_result # Error dict
_, arguments = policy_result
# MCP tools first — parity with the sync path (_execute_tool_impl).
# Without this an MCP-backed tool call hard-fails via achat()/astart().
# Only consult MCP when searching the agent's own tools (no override),
# matching the sync path which resolves MCP against self.tools.
if tools_override is None:
resolve_mcp = getattr(self, "_resolve_mcp_tool_result", None)
if resolve_mcp is not None:
found, mcp_result = resolve_mcp(function_name, arguments)
if found:
if inspect.isawaitable(mcp_result):
mcp_result = await mcp_result
# Normalize MCP transport/timeout failures into a native
# error dict — parity with the sync path so an async MCP
# timeout classifies as a retryable failure instead of
# being handed to the model as a bare "Error: ..." string
# that looks like a successful result.
normalize_mcp = getattr(self, "_normalize_mcp_result", None)
if normalize_mcp is not None:
mcp_result = normalize_mcp(mcp_result)
return mcp_result
# Try to find the function in the override tools list first, then agent's tools list.
# Resolve by BaseTool/FunctionTool ``.name`` (instances like BrowserBaseTool or
# aliased decorated tools), plain callable ``__name__``, or class name so async
@@ -1720,8 +1751,36 @@ Write the complete compiled report:"""
(inspect.isclass(tool) and tool.__name__ == function_name):
func = tool
break
# Name self-repair — parity with the sync path. A slightly hallucinated
# tool name (e.g. 'WebSearch' for 'web_search') is re-matched instead of
# hard-failing. Only over the agent's own active tools (no override).
if func is None and tools_override is None:
repair = getattr(self, "_self_repair_tool_name", None)
if repair is not None:
func = repair(function_name)
if func is None:
# Corrective, model-readable message with the available tools and a
# closest-match hint, mirroring the sync path so the model can retry.
available = None
if hasattr(self, "_available_active_tool_names"):
try:
available = self._available_active_tool_names()
except Exception:
available = None
if available is not None:
suggestion = None
try:
import difflib
near = difflib.get_close_matches(function_name, available, n=1, cutoff=0.5)
suggestion = near[0] if near else None
except Exception:
pass
hint = f" Did you mean '{suggestion}'?" if suggestion else ""
error_msg = f"Tool '{function_name}' not found.{hint} Available tools: {available}"
logging.error(error_msg)
return {"error": error_msg, "available_tools": available}
logging.error(f"Function {function_name} not found in tools")
return {"error": f"Function {function_name} not found in tools"}
@@ -1800,17 +1859,42 @@ Write the complete compiled report:"""
call_arguments = dict(arguments)
call_arguments["idempotency_key"] = durable_key
# Establish the injection context (Injected[T] params, including
# the cooperative cancel_event) around dispatch so the async path
# matches the sync execute_tool path. Without this, a long-running
# tool (e.g. shell) executed via achat()/astart() never observes an
# interrupt and runs until its own timeout. Built lazily and reused
# for both the coroutine and executor branches.
from ..tools.injected import with_injection_context
_inject_state = None
if hasattr(self, "_build_agent_state"):
try:
_inject_state = self._build_agent_state()
except Exception:
_inject_state = None
async def _invoke():
# Set the tool-progress channel BEFORE dispatch so the sink
# propagates into the executor thread via contextvars, mirroring
# the sync execute_tool path.
if inspect.iscoroutinefunction(call_target):
logging.debug(f"Executing async function: {function_name}")
if _inject_state is not None:
with with_injection_context(_inject_state), tool_progress_channel(_progress_sink):
return await call_target(**call_arguments)
with tool_progress_channel(_progress_sink):
return await call_target(**call_arguments)
logging.debug(f"Executing sync function in executor: {function_name}")
loop = asyncio.get_running_loop()
from ..trace.context_events import copy_context_to_callable
# copy_context_to_callable snapshots the CURRENT context (incl.
# the injection contextvar) into the executor thread, so setting
# it here propagates cancel_event to the sync tool body too.
if _inject_state is not None:
with with_injection_context(_inject_state), tool_progress_channel(_progress_sink):
return await loop.run_in_executor(
None, copy_context_to_callable(lambda: call_target(**call_arguments))
)
with tool_progress_channel(_progress_sink):
return await loop.run_in_executor(
None, copy_context_to_callable(lambda: call_target(**call_arguments))
@@ -6,14 +6,26 @@ operations. Follows protocol-driven design with zero overhead when not used.
"""
import threading
from typing import Optional, Protocol, Set
from typing import Optional, Protocol, Set, runtime_checkable
from dataclasses import dataclass, field
__all__ = ["InterruptControllerProtocol", "InterruptController"]
__all__ = [
"InterruptControllerProtocol",
"CancellableInterruptControllerProtocol",
"InterruptController",
]
class InterruptControllerProtocol(Protocol):
"""Protocol for interrupt controller extension point."""
"""Protocol for interrupt controller extension point.
``event`` is intentionally NOT part of this base contract: exposing the
underlying cancellation Event is an OPTIONAL capability. Existing third-party
controllers that only implement request/clear/is_set/reason/check remain
valid; the runtime probes for ``event`` via ``getattr(..., 'event', None)``.
Controllers that want in-tool cooperative abort can implement
:class:`CancellableInterruptControllerProtocol`.
"""
def request(self, reason: str = "user") -> None:
"""Request cancellation with optional reason."""
@@ -31,12 +43,27 @@ class InterruptControllerProtocol(Protocol):
def reason(self) -> Optional[str]:
"""Get interrupt reason if set."""
...
def check(self) -> None:
"""Check for interrupt and raise if set."""
...
@runtime_checkable
class CancellableInterruptControllerProtocol(InterruptControllerProtocol, Protocol):
"""Optional extension: controllers that expose the cancellation Event.
A running tool body can wait on / poll this Event to abort promptly instead
of running until its own timeout. Implementing it is entirely optional and
fully backward compatible with :class:`InterruptControllerProtocol`.
"""
@property
def event(self) -> "threading.Event":
"""Get the underlying cancellation Event for in-tool observation."""
...
@dataclass
class InterruptController:
"""Thread-safe cooperative cancellation for agent runs.
@@ -95,10 +122,19 @@ class InterruptController:
return turn_id in self._cancelled_turns
def _end_turn(self, turn_id: int) -> None:
"""Unregister a turn so its request cannot affect a future turn."""
"""Unregister a turn so its request cannot affect a future turn.
Also clears the underlying Event once no turn still needs it (no active
or still-cancelled turns and no pending request). The Event is exposed to
running tool bodies via :attr:`event`; leaving it set after an interrupted
turn would make a reused controller abort tools launched by the NEXT turn.
"""
with self._lock:
self._active_turns.discard(turn_id)
self._cancelled_turns.discard(turn_id)
if not self._active_turns and not self._cancelled_turns and not self._pending_turn_cancel:
self._flag.clear()
self._reason = None
def clear(self) -> None:
"""Clear the cancellation request."""
@@ -126,6 +162,16 @@ class InterruptController:
with self._lock:
return self._reason
@property
def event(self) -> threading.Event:
"""Expose the underlying cancellation Event.
Lets a running tool body observe the interrupt cooperatively (e.g. wait
on it with a timeout, or poll ``is_set()``) so an in-flight subprocess/
request can be aborted rather than left running until its own timeout.
"""
return self._flag
def check(self) -> None:
"""Check for cancellation and raise if requested.
@@ -9,8 +9,9 @@ for maintainability.
import os
import inspect
import logging
import threading
import contextvars
from typing import Optional
from typing import Optional, Dict
# Per-turn ownership of appended chat-history messages. Each chat()/achat() turn
# runs in its own thread (sync) or task (async); both get an isolated copy of
@@ -21,6 +22,38 @@ _active_turn_owned: "contextvars.ContextVar[Optional[list]]" = contextvars.Conte
"praisonai_active_turn_owned", default=None
)
# Registry of the ownership lists of every currently in-flight chat()/achat()
# turn, keyed by id() of the list. A turn registers its list in
# _begin_turn_tracking and removes it in _end_turn_tracking. ephemeral() reads
# this at exit so a message a *concurrent live turn* appended is preserved even
# when that append did not flow through the ephemeral tracker (e.g. the turn
# ran on another thread that never entered this block's context). Guarded by
# _live_turns_lock because turns on different threads mutate it concurrently.
_live_turn_owners: "Dict[int, list]" = {}
_live_turns_lock = threading.Lock()
def _register_live_turn(owned: list) -> None:
with _live_turns_lock:
_live_turn_owners[id(owned)] = owned
def _unregister_live_turn(owned: Optional[list]) -> None:
if owned is None:
return
with _live_turns_lock:
_live_turn_owners.pop(id(owned), None)
def _live_turn_message_ids() -> set:
"""ids of every message any currently in-flight turn has appended so far."""
with _live_turns_lock:
owners = list(_live_turn_owners.values())
ids = set()
for owned in owners:
ids.update(id(m) for m in owned)
return ids
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
from ._lazy_display import _get_console, _get_live, _get_display_functions
@@ -121,11 +154,18 @@ class MemoryMixin:
runs in its own thread (sync ``chat``) or task (async ``achat``), so the
context var copy is isolated per turn. Safe to call unconditionally; when
a turn opts in, its rollback removes only its own messages by identity.
The list is also registered in the process-wide live-turn registry so a
concurrent ``ephemeral()`` block on another thread can preserve messages
this in-flight turn appends (see ``_live_turn_message_ids``).
"""
return _active_turn_owned.set([])
owned: list = []
_register_live_turn(owned)
return _active_turn_owned.set(owned)
def _end_turn_tracking(self, token):
"""Stop recording for the current turn and drop its ownership list."""
_unregister_live_turn(_active_turn_owned.get())
try:
_active_turn_owned.reset(token)
except (ValueError, LookupError):
@@ -136,8 +176,10 @@ class MemoryMixin:
Used at the start of an async turn (whose control flow has no single
wrapping ``finally``) so a stale list from a completed turn on the same
task cannot accumulate messages from unrelated turns.
task cannot accumulate messages from unrelated turns. Also unregisters
that stale list from the live-turn registry so it cannot linger there.
"""
_unregister_live_turn(_active_turn_owned.get())
_active_turn_owned.set(None)
def _rollback_chat_history_to(self, rollback_length):
@@ -162,7 +204,7 @@ class MemoryMixin:
"""
owned = _active_turn_owned.get()
with self._history_lock:
if owned:
if owned is not None:
owned_ids = {id(m) for m in owned}
self.chat_history[:] = [
m for m in self.chat_history if id(m) not in owned_ids
@@ -200,9 +242,12 @@ class MemoryMixin:
Also resets _auto_save_last_index to prevent silent message loss
when auto_save is enabled.
"""
self.chat_history = []
# Reset auto-save index to prevent stale index causing message loss
self._auto_save_last_index = 0
# Hold the same lock every other history mutator takes so a concurrent
# _add_to_chat_history append can't land on the list object we discard.
with self._history_lock:
self.chat_history = []
# Reset auto-save index to prevent stale index causing message loss
self._auto_save_last_index = 0
def prune_history(self, keep_last: int = 5) -> int:
"""
@@ -296,16 +341,32 @@ class MemoryMixin:
response = agent.chat("[IMAGE] Analyze this")
# After block, history is restored - image NOT persisted
"""
# Save current history state
# Restore by identity, not by a blind ``chat_history = saved_history``.
# A message a concurrent chat()/achat() turn on the same shared Agent
# commits during the block must survive; only the ephemeral additions go.
#
# A message is KEPT on exit when it is either:
# * present in the pre-block snapshot (it predates the block), or
# * owned by a chat()/achat() turn that is still in flight (a live
# concurrent turn appended it - preserving it is the fix for the
# history-loss race).
# Everything else that appeared during the block is an ephemeral addition
# and is removed. This also drops direct ``chat_history.append`` writes
# made inside the block, which carry no turn ownership. Nested blocks
# compose: the inner block removes its own additions first, and the outer
# block's snapshot still holds everything that predates it.
with self._history_lock:
saved_history = self.chat_history.copy()
snapshot_ids = {id(m) for m in self.chat_history}
try:
yield
finally:
# Restore history to pre-block state
live_ids = _live_turn_message_ids()
keep_ids = snapshot_ids | live_ids
with self._history_lock:
self.chat_history = saved_history
self.chat_history[:] = [
m for m in self.chat_history if id(m) in keep_ids
]
def _init_db_session(self):
"""Initialize DB session if db adapter is provided (lazy, first chat only)."""
@@ -436,6 +436,50 @@ class ToolExecutionMixin:
logging.debug(f"Type casting failed for {getattr(func, '__name__', 'unknown function')}: {e}")
return arguments
def _current_cancel_event(self):
"""Return a cancellation Event a running tool body can observe, or None.
Sourced from the agent's ``interrupt_controller`` when present. The event
is only meaningful while a cancellation is actually in effect for the
current turn — the controller clears its underlying flag once an
interrupted turn ends (see ``InterruptController._end_turn``), so a stale
request from a previous turn does not abort newly started tool bodies.
A controller without an ``event`` member yields ``None`` (fully optional).
"""
controller = getattr(self, 'interrupt_controller', None)
if controller is None:
return None
return getattr(controller, 'event', None)
def _build_agent_state(self):
"""Construct the AgentState injected into tools for the current call.
Shared by the sync (``execute_tool``) and async
(``_execute_tool_async_impl``) paths so both establish an identical
injection context — including the cooperative ``cancel_event`` — avoiding
drift where one path silently omits injected state.
"""
from ..tools.injected import AgentState
try:
from .durable import get_durable_idempotency_key
idempotency_key = get_durable_idempotency_key()
except ImportError:
idempotency_key = None
state_metadata = {'agent_name': self.name}
if idempotency_key:
state_metadata['idempotency_key'] = idempotency_key
return AgentState(
agent_id=self.name,
run_id=getattr(self, '_current_run_id', 'unknown'),
session_id=getattr(self, '_session_id', None) or 'default',
last_user_message=self.chat_history[-1].get('content') if self.chat_history else None,
memory=getattr(self, '_memory_instance', None),
learn_manager=getattr(getattr(self, '_memory_instance', None), 'learn', None),
metadata=state_metadata,
cancel_event=self._current_cancel_event(),
)
def execute_tool(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None) -> Any:
"""
Execute a tool dynamically based on the function name and arguments.
@@ -459,25 +503,8 @@ class ToolExecutionMixin:
# Do NOT call it here to avoid duplicate output
# Set up injection context for tools with Injected parameters
from ..tools.injected import AgentState
try:
from .durable import get_durable_idempotency_key
idempotency_key = get_durable_idempotency_key()
except ImportError:
idempotency_key = None
state_metadata = {'agent_name': self.name}
if idempotency_key:
state_metadata['idempotency_key'] = idempotency_key
state = AgentState(
agent_id=self.name,
run_id=getattr(self, '_current_run_id', 'unknown'),
session_id=getattr(self, '_session_id', None) or 'default',
last_user_message=self.chat_history[-1].get('content') if self.chat_history else None,
memory=getattr(self, '_memory_instance', None),
learn_manager=getattr(getattr(self, '_memory_instance', None), 'learn', None),
metadata=state_metadata,
)
state = self._build_agent_state()
cancel_event = state.cancel_event
# Route through user-supplied tool middleware (Agent(hooks=[...])) when
# present. Zero overhead when no hooks: the fast path calls straight
@@ -496,6 +523,7 @@ class ToolExecutionMixin:
session_id=getattr(self, '_session_id', None) or 'default',
tool_name=function_name,
metadata={"tool_call_id": tool_call_id},
cancel_event=cancel_event,
),
)
@@ -2557,86 +2585,15 @@ class ToolExecutionMixin:
return policy_result # Error dict
_, arguments = policy_result
# Special handling for MCP tools
# Check if tools is an MCP instance with the requested function name
MCP = None
try:
from ..mcp.mcp import MCP
except ImportError:
pass # MCP not available
# Normalize MCP transport/timeout failures (returned as bare "Error: ..."
# strings by MCPClient.call_tool) into the same {"error": ...} dict shape
# used by native tools, so they participate in retry, doom-loop detection
# and circuit-breaking instead of looking like a successful string result.
#
# IMPORTANT: a successful MCP tool may legitimately return text that
# begins with "Error: " (e.g. a linter/grep/echo tool). To avoid
# misclassifying valid output as a failure, we only convert MCP's own
# unambiguous internally-generated failure signatures, not any string
# that merely starts with the "Error: " prefix.
def _normalize_mcp_result(res):
if not isinstance(res, str) or not res.startswith("Error: "):
return res
message = res[len("Error: "):]
# MCP-internal timeout messages are unambiguous:
# "MCP tool call timed out after Ns"
# "MCP initialization timed out after Ns"
is_mcp_timeout = (
message.startswith("MCP tool call timed out after")
or message.startswith("MCP initialization timed out after")
)
if is_mcp_timeout:
return {"error": message, "timeout": True}
return res
# Helper function to execute MCP tool
def _execute_mcp_tool(mcp_instance, func_name, args):
"""Execute a tool from an MCP instance."""
# Handle SSE MCP client
if hasattr(mcp_instance, 'is_sse') and mcp_instance.is_sse:
if hasattr(mcp_instance, 'sse_client'):
for tool in mcp_instance.sse_client.tools:
if tool.name == func_name:
logging.debug(f"Found matching SSE MCP tool: {func_name}")
return True, tool(**args)
# Handle HTTP Stream MCP client
if hasattr(mcp_instance, 'is_http_stream') and mcp_instance.is_http_stream:
if hasattr(mcp_instance, 'http_stream_client'):
for tool in mcp_instance.http_stream_client.tools:
if tool.name == func_name:
logging.debug(f"Found matching HTTP Stream MCP tool: {func_name}")
return True, tool(**args)
# Handle WebSocket MCP client
if hasattr(mcp_instance, 'is_websocket') and mcp_instance.is_websocket:
if hasattr(mcp_instance, 'websocket_client'):
for tool in mcp_instance.websocket_client.tools:
if tool.name == func_name:
logging.debug(f"Found matching WebSocket MCP tool: {func_name}")
return True, tool(**args)
# Handle stdio MCP client
if hasattr(mcp_instance, 'runner'):
for mcp_tool in mcp_instance.runner.tools:
if hasattr(mcp_tool, 'name') and mcp_tool.name == func_name:
logging.debug(f"Found matching MCP tool: {func_name}")
return True, mcp_instance.runner.call_tool(func_name, args)
return False, None
# Check if tools is a single MCP instance
if MCP is not None and isinstance(self.tools, MCP):
logging.debug(f"Looking for MCP tool {function_name}")
found, result = _execute_mcp_tool(self.tools, function_name, arguments)
if found:
return _normalize_mcp_result(result)
# Check if tools is a list that may contain MCP instances
if isinstance(self.tools, (list, tuple)):
for tool in self.tools:
if MCP is not None and isinstance(tool, MCP):
logging.debug(f"Looking for MCP tool {function_name} in MCP instance")
found, result = _execute_mcp_tool(tool, function_name, arguments)
if found:
return _normalize_mcp_result(result)
# Resolve MCP-backed tools via the shared helper (also used by the async
# path) so chat()/achat() behave identically for MCP tool calls. MCP
# transport/timeout failures are normalized (via the shared
# _normalize_mcp_result) into a native {"error": ..., "timeout": True}
# dict so they participate in retry/doom-loop/circuit handling instead of
# looking like a successful string result.
found, result = self._resolve_mcp_tool_result(function_name, arguments)
if found:
return self._normalize_mcp_result(result)
# Try to find the function in the agent's tools list first
func = None
@@ -2670,21 +2627,10 @@ class ToolExecutionMixin:
if func is None:
# Tool not found in declared tools or registry — do not fall back to
# globals() or __main__ as that allows undeclared callables to execute.
# Cheap, deterministic self-repair: the model often emits a name that
# only differs by case/separator (e.g. 'WebSearch' -> 'web_search').
# Build a normalised index of the agent's active tools and re-match.
def _norm(n):
return str(n).lower().replace('_', '').replace('-', '').replace(' ', '')
normalised = {}
for name, tool in self._iter_active_named_tools():
normalised.setdefault(_norm(name), []).append((name, tool))
match = normalised.get(_norm(function_name))
if match and len(match) == 1:
matched_name, func = match[0]
logging.debug(
f"Self-repaired tool name {function_name!r} -> {matched_name!r}"
)
# Cheap, deterministic self-repair via the shared helper (also used by
# the async path): the model often emits a name that only differs by
# case/separator (e.g. 'WebSearch' -> 'web_search').
func = self._self_repair_tool_name(function_name)
if func:
bind_target = func
@@ -2714,6 +2660,16 @@ class ToolExecutionMixin:
scoped["idempotency_key"] = durable_key
return scoped
# Any of the callables below (BaseTool.run, LangChain .run,
# CrewAI ._run, or a plain function) may be `async def`. On this
# sync tool-calling path we must await it, otherwise a bare
# un-awaited coroutine is handed to the model as the tool result
# and the tool body never runs (silent data loss).
def _resolve_result(value):
if inspect.iscoroutine(value):
return self._run_async_in_sync_context(value)
return value
# BaseTool instances (plugin system) - call run() method
from ..tools.base import BaseTool
if isinstance(func, BaseTool):
@@ -2721,7 +2677,7 @@ class ToolExecutionMixin:
keyed_arguments = _with_durable_key(func.run, arguments)
casted_arguments = self._cast_arguments(func.run, keyed_arguments)
bind_arguments = casted_arguments
return func.run(**casted_arguments)
return _resolve_result(func.run(**casted_arguments))
# Langchain: If it's a class with run but not _run, instantiate and call run
if inspect.isclass(func) and hasattr(func, 'run') and not hasattr(func, '_run'):
@@ -2733,7 +2689,7 @@ class ToolExecutionMixin:
and k != 'self'}
casted_params = self._cast_arguments(instance.run, run_params)
bind_arguments = casted_params
return instance.run(**casted_params)
return _resolve_result(instance.run(**casted_params))
# CrewAI: If it's a class with an _run method, instantiate and call _run
elif inspect.isclass(func) and hasattr(func, '_run'):
@@ -2745,7 +2701,7 @@ class ToolExecutionMixin:
and k != 'self'}
casted_params = self._cast_arguments(instance._run, run_params)
bind_arguments = casted_params
return instance._run(**casted_params)
return _resolve_result(instance._run(**casted_params))
# Otherwise treat as regular function
elif callable(func):
@@ -2753,7 +2709,7 @@ class ToolExecutionMixin:
keyed_arguments = _with_durable_key(func, arguments)
casted_arguments = self._cast_arguments(func, keyed_arguments)
bind_arguments = casted_arguments
return func(**casted_arguments)
return _resolve_result(func(**casted_arguments))
except Exception as e:
error_msg = str(e)
logging.error(f"Error executing tool {function_name}: {error_msg}")
@@ -2904,6 +2860,103 @@ class ToolExecutionMixin:
names = [name for name, _tool in self._iter_active_named_tools()]
return sorted(set(names))
@staticmethod
def _normalize_mcp_result(res):
"""Normalize MCP transport/timeout failures into a native error dict.
``MCPClient.call_tool`` surfaces transport/timeout failures as bare
``"Error: ..."`` strings. Converting MCP's own unambiguous
internally-generated timeout signatures into ``{"error": ..., "timeout":
True}`` lets them participate in retry, doom-loop detection and circuit
breaking instead of looking like a successful string result. Shared by
the sync (``_execute_tool_impl``) and async (``_execute_tool_async_impl``)
paths so achat()/chat() classify an MCP timeout identically.
A successful MCP tool may legitimately return text starting with
``"Error: "`` (e.g. a linter/grep/echo tool), so only MCP's own
unambiguous timeout messages are converted, never any string that merely
starts with the prefix.
"""
if not isinstance(res, str) or not res.startswith("Error: "):
return res
message = res[len("Error: "):]
is_mcp_timeout = (
message.startswith("MCP tool call timed out after")
or message.startswith("MCP initialization timed out after")
)
if is_mcp_timeout:
return {"error": message, "timeout": True}
return res
def _resolve_mcp_tool_result(self, function_name, arguments):
"""Resolve an MCP-backed tool and return ``(found, result)``.
Shared by the sync (``_execute_tool_impl``) and async
(``_execute_tool_async_impl``) paths so an MCP tool call behaves
identically regardless of chat()/achat(). Returns ``(False, None)``
when no MCP tool matches. The ``result`` may be awaitable (the async
caller awaits it); the sync caller normalizes/returns it directly.
"""
MCP = None
try:
from ..mcp.mcp import MCP
except ImportError:
return False, None
def _execute_mcp_tool(mcp_instance, func_name, args):
if hasattr(mcp_instance, 'is_sse') and mcp_instance.is_sse:
if hasattr(mcp_instance, 'sse_client'):
for tool in mcp_instance.sse_client.tools:
if tool.name == func_name:
return True, tool(**args)
if hasattr(mcp_instance, 'is_http_stream') and mcp_instance.is_http_stream:
if hasattr(mcp_instance, 'http_stream_client'):
for tool in mcp_instance.http_stream_client.tools:
if tool.name == func_name:
return True, tool(**args)
if hasattr(mcp_instance, 'is_websocket') and mcp_instance.is_websocket:
if hasattr(mcp_instance, 'websocket_client'):
for tool in mcp_instance.websocket_client.tools:
if tool.name == func_name:
return True, tool(**args)
if hasattr(mcp_instance, 'runner'):
for mcp_tool in mcp_instance.runner.tools:
if hasattr(mcp_tool, 'name') and mcp_tool.name == func_name:
return True, mcp_instance.runner.call_tool(func_name, args)
return False, None
if isinstance(self.tools, MCP):
found, result = _execute_mcp_tool(self.tools, function_name, arguments)
if found:
return True, result
if isinstance(self.tools, (list, tuple)):
for tool in self.tools:
if isinstance(tool, MCP):
found, result = _execute_mcp_tool(tool, function_name, arguments)
if found:
return True, result
return False, None
def _self_repair_tool_name(self, function_name):
"""Case/separator-insensitive re-match of a hallucinated tool name.
Returns the matched tool object when exactly one active tool normalizes
to the same key (e.g. 'WebSearch' -> 'web_search'), else ``None``.
Shared by the sync and async tool-resolution paths.
"""
def _norm(n):
return str(n).lower().replace('_', '').replace('-', '').replace(' ', '')
normalised = {}
for name, tool in self._iter_active_named_tools():
normalised.setdefault(_norm(name), []).append((name, tool))
match = normalised.get(_norm(function_name))
if match and len(match) == 1:
matched_name, tool = match[0]
logging.debug(f"Self-repaired tool name {function_name!r} -> {matched_name!r}")
return tool
return None
def _resolve_callable_signature_target(self, func):
"""Return the callable whose signature describes ``func``'s arguments."""
target = func
@@ -3536,8 +3536,13 @@ class AgentTeam(SpawnAnnounceProtocol):
This method is safe to call from sync contexts only.
"""
with self._spawn_lock:
# Create unique IDs
# Create unique IDs. agent_id keys self._spawned_agents, so retry on
# the (rare) truncated-UUID collision rather than silently clobbering
# an existing spawn record - the same guard the endpoint-path
# generator uses.
agent_id = f"{self._team_id}_{str(uuid.uuid4())[:8]}"
while agent_id in self._spawned_agents:
agent_id = f"{self._team_id}_{str(uuid.uuid4())[:8]}"
task_id = f"task_{str(uuid.uuid4())[:8]}"
# Ensure event bus is initialized
@@ -3740,8 +3745,13 @@ class AgentTeam(SpawnAnnounceProtocol):
self._async_spawn_lock = asyncio.Lock()
async with self._async_spawn_lock:
# Create unique IDs
# Create unique IDs. agent_id keys self._spawned_agents, so retry on
# the (rare) truncated-UUID collision rather than silently clobbering
# an existing spawn record - the same guard the endpoint-path
# generator uses.
agent_id = f"{self._team_id}_{str(uuid.uuid4())[:8]}"
while agent_id in self._spawned_agents:
agent_id = f"{self._team_id}_{str(uuid.uuid4())[:8]}"
task_id = f"task_{str(uuid.uuid4())[:8]}"
# Ensure event bus is initialized
@@ -10,7 +10,7 @@ from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Protocol, Tuple, runtime_checkable
from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, runtime_checkable
def _new_approval_id() -> str:
@@ -38,6 +38,14 @@ class ApprovalRequest:
backend may bind resolution to these reviewers so that in a
multi-operator deployment a request addressed to one
operator cannot be resolved by another.
liveness: Optional zero-arg predicate returning ``True`` while the
originating turn is still the current one. ``None``
(default) is a no-op — the request is treated as always
live, preserving today's behaviour. A backend may consult
this at the resolution/execution boundary to drop a
resolution that arrives after the turn was superseded or
stopped (fail-closed), so a stale approval never fires a
tool or delivers a reply for an abandoned turn.
"""
tool_name: str
@@ -48,6 +56,7 @@ class ApprovalRequest:
context: Dict[str, Any] = field(default_factory=dict)
approval_id: str = field(default_factory=_new_approval_id)
authorized_reviewers: Optional[List[str]] = None
liveness: Optional[Callable[[], bool]] = None
@dataclass
@@ -441,6 +441,44 @@ class BasePlatformAdapter(ABC):
"""
await self.disconnect()
# ------------------------------------------------------------------ #
# Identity canonicalization seam. #
# #
# The gateway keys each conversation on the platform-supplied user id.#
# When a platform changes the *native* id for the *same human* #
# (WhatsApp JID→LID migration, a renamed handle-as-id, a #
# phone-number↔UUID alias flip) the raw id changes and the session #
# would silently fork — history/memory/run-state orphaned. This seam #
# lets an adapter map a volatile raw id to a stable canonical id #
# *before* it becomes part of the session key; the resolver's #
# explicit link/pairing lookup then runs on the stabilised id. #
# #
# It fulfils ``IdentityCanonicalizerProtocol`` (praisonaiagents. #
# gateway.protocols) so an adapter *is* its own canonicalizer: pass #
# ``self`` where that protocol is accepted. #
# ------------------------------------------------------------------ #
def canonicalize(self, platform: str, raw_user_id: str) -> str:
"""Map a raw, potentially-volatile platform id to a stable canonical id.
Consulted on the session-key path *before* link/pairing resolution so
an alias/format change for the *same* human (e.g. WhatsApp's JID→LID
migration, a number↔UUID alias flip) collapses to one stable session
key — preserving transcript, memory and in-flight run-state — instead
of silently forking the conversation.
This is the :class:`~praisonaiagents.gateway.protocols.
IdentityCanonicalizerProtocol` method, so a ``BasePlatformAdapter``
conforms structurally and can be handed anywhere that protocol is
accepted.
Default is the identity function, so adapters that do not override this
are fully backward-compatible (the raw id keys the session exactly as
before). Implementations MUST be deterministic and total: return the
raw id unchanged when no canonical form is known, never raise.
"""
return raw_user_id
# ------------------------------------------------------------------ #
# Capability helpers #
# ------------------------------------------------------------------ #
@@ -335,6 +335,13 @@ class GatewayRuntimeSeams:
Attributes:
identity_resolver: Cross-platform identity resolver; unifies the same
human across platforms onto one session key.
identity_canonicalizer: Identity-canonicalization seam (Issue #4661) —
an :class:`IdentityCanonicalizerProtocol`
(``canonicalize(platform, raw_user_id) -> str``) applied *before*
the resolver, e.g. an adapter itself (see
:meth:`BasePlatformAdapter.canonicalize`). Stabilises a volatile
platform id (WhatsApp JID→LID, a number↔UUID alias flip) so the same
human keeps one session instead of silently forking.
delivery_router: Backing router for the built-in ``send_message`` tool,
so an agent turn can proactively reach the user mid-task.
admission_gate: Gateway-wide admission control (concurrency ceiling /
@@ -347,6 +354,7 @@ class GatewayRuntimeSeams:
"""
identity_resolver: Optional[Any] = None
identity_canonicalizer: Optional[Any] = None
delivery_router: Optional[Any] = None
admission_gate: Optional[Any] = None
turn_lock_map: Optional[Any] = None
@@ -139,7 +139,12 @@ class ContextCompactor:
# Anti-thrashing state tracking
self._last_savings_pct: float = 100.0 # Start high to allow first compaction
self._low_savings_streak: int = 0
# Token count at the moment the streak reached its cap. The guard is
# released once the conversation grows materially past this, so a
# compactor that once could not shrink the context is never disabled
# for the rest of the session.
self._low_savings_tokens: int = 0
# Iterative summary state
self._previous_summary: Optional[str] = None
self._previous_summary_global_idx: int = 0
@@ -208,14 +213,56 @@ class ContextCompactor:
- Token count is below threshold
- We've had too many consecutive low-savings attempts
"""
if self.count_total_tokens(messages) <= self.max_tokens:
total_tokens = self.count_total_tokens(messages)
if total_tokens <= self.max_tokens:
return False
# Anti-thrashing: skip if we've had too many low-savings attempts
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
# Anti-thrashing: skip if we've had too many low-savings attempts,
# unless the conversation has grown enough to be worth re-testing.
if self._should_skip_low_savings(total_tokens):
return False
return True
#: Growth factor that releases the anti-thrashing guard. A conversation
#: 25% larger than the one we gave up on is materially different input,
#: so the earlier "not worth compacting" verdict is re-tested.
_LOW_SAVINGS_RETRY_GROWTH = 1.25
def _should_skip_low_savings(self, original_tokens: int) -> bool:
"""Whether the anti-thrashing guard should skip this pass.
The guard stops a compactor that cannot shrink a conversation from
burning a summarisation call every turn. It must not be permanent:
``_low_savings_streak`` is only cleared *after* a compaction actually
runs, so a compactor that trips the cap would otherwise skip forever
and let the context grow without bound -- the precise opposite of what
the guard is for.
Growth past the size we gave up on is new evidence, so the verdict is
retried and the streak reset.
"""
if self._low_savings_streak < self.config.max_consecutive_low_savings:
return False
if self._low_savings_tokens and original_tokens >= (
self._low_savings_tokens * self._LOW_SAVINGS_RETRY_GROWTH
):
self._low_savings_streak = 0
self._low_savings_tokens = 0
return False
return True
def _record_savings_outcome(self, savings_pct: float, original_tokens: int) -> None:
"""Update anti-thrashing state after a completed compaction pass."""
self._last_savings_pct = savings_pct
if savings_pct >= self.config.min_savings_pct:
self._low_savings_streak = 0
self._low_savings_tokens = 0
return
self._low_savings_streak += 1
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
# Remember the size we gave up at, so growth can release the guard.
self._low_savings_tokens = original_tokens
def compact(
self,
@@ -254,7 +301,7 @@ class ContextCompactor:
# Anti-thrashing: if prior passes yielded too little savings, skip and
# surface it on the result so callers can detect the skip. Without this
# the documented ``was_skipped_due_to_low_savings`` flag never fired.
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
if self._should_skip_low_savings(original_tokens):
result = CompactionResult(
original_tokens=original_tokens,
compacted_tokens=original_tokens,
@@ -323,11 +370,7 @@ class ContextCompactor:
result.calculate_savings_pct()
# Update anti-thrashing tracking based on actual results
self._last_savings_pct = result.savings_pct
if result.savings_pct >= self.config.min_savings_pct:
self._low_savings_streak = 0
else:
self._low_savings_streak += 1
self._record_savings_outcome(result.savings_pct, original_tokens)
return compacted, result
@@ -365,7 +408,7 @@ class ContextCompactor:
# Anti-thrashing: if prior passes yielded too little savings, skip and
# surface it on the result so callers can detect the skip. Without this
# the documented ``was_skipped_due_to_low_savings`` flag never fired.
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
if self._should_skip_low_savings(original_tokens):
result = CompactionResult(
original_tokens=original_tokens,
compacted_tokens=original_tokens,
@@ -416,11 +459,7 @@ class ContextCompactor:
result.calculate_savings_pct()
# Update anti-thrashing tracking based on actual results
self._last_savings_pct = result.savings_pct
if result.savings_pct >= self.config.min_savings_pct:
self._low_savings_streak = 0
else:
self._low_savings_streak += 1
self._record_savings_outcome(result.savings_pct, original_tokens)
return compacted, result
@@ -204,52 +204,61 @@ def evaluate_condition(
else:
substituted = substituted.replace(placeholder, str(value))
# A comparison operator in the *template* (not the substituted values)
# is what makes this a comparison condition. When the template has no
# operator, it is a plain flag/contains/truthy check and the comparison
# parsers below must be skipped, otherwise a placeholder whose value
# happens to look like a comparison (e.g. "{{flag}}" -> "5 < 3") would be
# mis-parsed as a comparison instead of honoring the truthy-string path.
template_has_comparison = re.search(r'>=|<=|==|!=|>|<', condition) is not None
# Now evaluate the substituted condition
try:
# Handle different condition formats
# Check if we have unsubstituted empty values in comparisons
# If a variable was missing, it becomes empty string which can cause issues
if ' > ' in substituted or ' < ' in substituted or ' >= ' in substituted or ' <= ' in substituted:
# Check for empty left side in comparison (missing variable)
if substituted.strip().startswith('>') or substituted.strip().startswith('<'):
return False
if substituted.strip().startswith('='):
return False
# Numeric comparisons: "90 > 80", "50 >= 50", "10 < 20", etc.
numeric_pattern = r'^(-?\d+(?:\.\d+)?)\s*(>|>=|<|<=|==|!=)\s*(-?\d+(?:\.\d+)?)$'
numeric_match = re.match(numeric_pattern, substituted.strip())
if numeric_match:
left = float(numeric_match.group(1))
op = numeric_match.group(2)
right = float(numeric_match.group(3))
if template_has_comparison:
# Check if we have unsubstituted empty values in comparisons
# If a variable was missing, it becomes empty string which can cause issues
if ' > ' in substituted or ' < ' in substituted or ' >= ' in substituted or ' <= ' in substituted:
# Check for empty left side in comparison (missing variable)
if substituted.strip().startswith('>') or substituted.strip().startswith('<'):
return False
if substituted.strip().startswith('='):
return False
if op == '>':
return left > right
if op == '>=':
return left >= right
if op == '<':
return left < right
if op == '<=':
return left <= right
if op == '==':
return left == right
if op == '!=':
return left != right
# String equality: "approved == approved", "status != rejected"
string_eq_pattern = r'^(.+?)\s*(==|!=)\s*(.+)$'
string_match = re.match(string_eq_pattern, substituted.strip())
if string_match:
left = string_match.group(1).strip()
op = string_match.group(2)
right = string_match.group(3).strip()
# Numeric comparisons: "90 > 80", "50 >= 50", "10 < 20", etc.
numeric_pattern = r'^(-?\d+(?:\.\d+)?)\s*(>|>=|<|<=|==|!=)\s*(-?\d+(?:\.\d+)?)$'
numeric_match = re.match(numeric_pattern, substituted.strip())
if numeric_match:
left = float(numeric_match.group(1))
op = numeric_match.group(2)
right = float(numeric_match.group(3))
if op == '>':
return left > right
if op == '>=':
return left >= right
if op == '<':
return left < right
if op == '<=':
return left <= right
if op == '==':
return left == right
if op == '!=':
return left != right
if op == '==':
return left == right
if op == '!=':
return left != right
# String equality: "approved == approved", "status != rejected"
string_eq_pattern = r'^(.+?)\s*(==|!=)\s*(.+)$'
string_match = re.match(string_eq_pattern, substituted.strip())
if string_match:
left = string_match.group(1).strip()
op = string_match.group(2)
right = string_match.group(3).strip()
if op == '==':
return left == right
if op == '!=':
return left != right
# Contains check: "error in some message", "status contains success"
if ' in ' in substituted:
@@ -272,7 +281,16 @@ def evaluate_condition(
return True
if substituted.strip().lower() == 'false':
return False
# A comparison operator in the template survived every format-specific
# check above, so the comparison it was meant to drive never matched
# (e.g. a missing or non-numeric operand). Fail safe per this
# function's own contract instead of treating the leftover operator
# text as a truthy string. Plain flag values (no template operator)
# are untouched and fall through to the truthy check below.
if template_has_comparison:
return False
# Non-empty string is truthy
return bool(substituted.strip())
@@ -68,6 +68,10 @@ __all__ = [
"EvalReport",
"EvalPackage",
"EvalRunnerProtocol",
# EvalPort adapter (framework-neutral interop; dependency-free)
"to_evalport",
"from_evalport",
"report_to_evalport",
# Unified Judge API (follows add_X/get_X/list_X naming)
"Judge",
"JudgeConfig",
@@ -160,6 +164,10 @@ _LAZY_IMPORTS = {
"EvalReport": ("praisonaiagents.eval.package", "EvalReport"),
"EvalPackage": ("praisonaiagents.eval.package", "EvalPackage"),
"EvalRunnerProtocol": ("praisonaiagents.eval.package", "EvalRunnerProtocol"),
# EvalPort adapter (framework-neutral interop; dependency-free)
"to_evalport": ("praisonaiagents.eval.evalport", "to_evalport"),
"from_evalport": ("praisonaiagents.eval.evalport", "from_evalport"),
"report_to_evalport": ("praisonaiagents.eval.evalport", "report_to_evalport"),
# Unified Judge API (follows add_X/get_X/list_X naming)
"Judge": ("praisonaiagents.eval.judge", "Judge"),
"JudgeConfig": ("praisonaiagents.eval.judge", "JudgeConfig"),
@@ -0,0 +1,165 @@
"""
EvalPort adapter - framework-neutral interop for the eval package.
Converts PraisonAI's native eval data models to/from the EvalPort open spec
(https://github.com/adhabnr-ux/evalport), so a suite built here can run against
another tool's harness, and results can be diffed/aggregated across tools.
Mapping (1:1 with EvalPort's ``Suite`` / ``ResultSet`` concepts):
EvalPackage <-> EvalPort Suite (name/description/version + cases + thresholds)
EvalCase <-> EvalPort test case (input / expected_output / grader refs)
EvalReport -> EvalPort ResultSet (aggregate + per-case pass/score/latency)
Design: this module is intentionally **dependency-free**. It emits and consumes
plain spec-shaped dicts (JSON), so the core SDK does not take on ``evalport-sdk``
as a dependency. Callers who want schema validation can pass the returned dict to
``openeval.validate.validate_suite()`` / ``validate_result_set()`` themselves.
Example:
>>> from praisonaiagents.eval import EvalPackage, EvalCase
>>> from praisonaiagents.eval import to_evalport, from_evalport, report_to_evalport
>>> pkg = EvalPackage(name="support", cases=[
... EvalCase(name="refund", input="refund policy?", expected="30 days")
... ], thresholds={"accuracy": 0.9})
>>> suite = to_evalport(pkg) # EvalPackage -> EvalPort Suite dict
>>> pkg2 = from_evalport(suite) # EvalPort Suite dict -> EvalPackage
>>> pkg2.name == pkg.name
True
"""
from typing import Any, Dict, List
from .package import EvalCase, EvalPackage, EvalReport
EVALPORT_SPEC_VERSION = "1.0"
def _case_to_evalport(case: EvalCase) -> Dict[str, Any]:
"""Map an ``EvalCase`` to an EvalPort test-case dict.
The native ``timeout_seconds`` is emitted as a dedicated top-level field so
it never collides with a user-supplied ``metadata["timeout_seconds"]`` key,
keeping round trips lossless for both.
"""
item: Dict[str, Any] = {
"id": case.name,
"input": case.input,
}
if case.expected is not None:
item["expected_output"] = case.expected
if case.criteria:
item["graders"] = list(case.criteria)
if case.timeout_seconds is not None:
item["timeout_seconds"] = case.timeout_seconds
metadata = dict(case.metadata or {})
if metadata:
item["metadata"] = metadata
return item
def _case_from_evalport(item: Dict[str, Any]) -> EvalCase:
"""Map an EvalPort test-case dict to an ``EvalCase``.
Prefers the dedicated top-level ``timeout_seconds`` field; falls back to a
``metadata["timeout_seconds"]`` value for suites emitted by other tools,
without mutating the caller's metadata.
"""
metadata = dict(item.get("metadata") or {})
if "timeout_seconds" in item:
timeout = item["timeout_seconds"]
else:
timeout = metadata.get("timeout_seconds", 30.0)
return EvalCase(
name=item.get("id") or item.get("name") or "case",
input=item.get("input", ""),
expected=item.get("expected_output", item.get("expected")),
criteria=list(item.get("graders") or item.get("criteria") or []),
metadata=metadata,
timeout_seconds=timeout,
)
def to_evalport(package: EvalPackage) -> Dict[str, Any]:
"""Convert an ``EvalPackage`` to an EvalPort ``Suite`` dict.
Args:
package: The native PraisonAI eval package.
Returns:
A spec-shaped dict suitable for ``openeval.validate.validate_suite()``.
"""
return {
"evalport_version": EVALPORT_SPEC_VERSION,
"kind": "suite",
"name": package.name,
"description": package.description,
"version": package.version,
"cases": [_case_to_evalport(c) for c in package.cases],
"thresholds": dict(package.thresholds or {}),
"seed": package.seed,
}
def from_evalport(suite: Dict[str, Any]) -> EvalPackage:
"""Convert an EvalPort ``Suite`` dict to a native ``EvalPackage``.
Args:
suite: A spec-shaped EvalPort suite dict (e.g. from the Benchmark Hub).
Returns:
An ``EvalPackage`` whose cases can be run via ``EvalSuite`` /
``HarnessEvaluator`` as native ``EvalCase`` objects.
"""
cases: List[EvalCase] = [_case_from_evalport(c) for c in suite.get("cases", [])]
return EvalPackage(
name=suite.get("name", "suite"),
description=suite.get("description", ""),
version=suite.get("version", "1.0.0"),
cases=cases,
thresholds=dict(suite.get("thresholds") or {}),
seed=suite.get("seed"),
)
def report_to_evalport(report: EvalReport) -> Dict[str, Any]:
"""Convert an ``EvalReport`` to an EvalPort ``ResultSet`` dict.
Args:
report: The aggregated report from running an eval package.
Returns:
A spec-shaped dict suitable for
``openeval.validate.validate_result_set()``.
"""
results: List[Dict[str, Any]] = []
for r in report.results:
item: Dict[str, Any] = {
"case_id": r.case_name,
"passed": r.passed,
"score": r.score,
"latency_ms": r.latency_ms,
}
if r.actual_output is not None:
item["actual_output"] = r.actual_output
if r.error is not None:
item["error"] = r.error
if r.criteria_scores:
item["grader_scores"] = dict(r.criteria_scores)
if r.record is not None:
item["record"] = r.record
results.append(item)
return {
"evalport_version": EVALPORT_SPEC_VERSION,
"kind": "result_set",
"suite_name": report.package_name,
"summary": {
"total": report.total_cases,
"passed": report.passed_cases,
"failed": report.failed_cases,
"pass_rate": report.pass_rate,
"average_score": report.average_score,
"thresholds_met": dict(report.thresholds_met or {}),
},
"results": results,
}
@@ -127,6 +127,12 @@ from .protocols import (
FatalConfigError,
classify_exit_reason,
RestartLoopGuard,
# Durable process-lifecycle record + unclean/OOM classification (Issue #4603)
LifecycleRecord,
classify_unclean_exit,
RestartStoreProtocol,
PersistentRestartLoopGuard,
classify_resource_pressure,
# Fleet-level crash-loop breaker for channel supervision (Issue #3840)
FleetSupervisionPolicy,
# Protocol version negotiation
@@ -407,6 +413,12 @@ __all__ = [
"FatalConfigError",
"classify_exit_reason",
"RestartLoopGuard",
# Durable process-lifecycle record + unclean/OOM classification (Issue #4603)
"LifecycleRecord",
"classify_unclean_exit",
"RestartStoreProtocol",
"PersistentRestartLoopGuard",
"classify_resource_pressure",
# Fleet-level crash-loop breaker for channel supervision (Issue #3840)
"FleetSupervisionPolicy",
# Protocol version negotiation
@@ -4803,6 +4803,308 @@ class RestartLoopGuard:
self._events = []
# ---------------------------------------------------------------------------
# Process-lifecycle record + unclean/OOM classification (Issue #4603)
#
# ``RestartLoopGuard`` above tracks the *rate* of restart-worthy boots in
# memory, and ``classify_exit_reason`` maps a caught exception to an exit code.
# Neither survives a ``SIGKILL``/OOM-kill — no in-process hook runs for those —
# so a hosted gateway cannot tell on its next boot that its previous process
# died uncleanly. These three pure primitives close that gap: a small durable
# lifecycle *record* the runtime restamps on boot/shutdown, a pure predicate
# that classifies "was the last exit clean?", and a restart-loop guard whose
# events load from / flush to an injected durable store so the breaker survives
# the very death it guards. All I/O — the ``/proc``/cgroup sampling and the
# durable sentinel — lives in the ``praisonai-bot`` runtime; core stays
# protocol + pure default policy, symmetric with the block above.
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class LifecycleRecord:
"""Durable, boot-scoped snapshot of the gateway process's lifecycle.
Written by the runtime at boot (``phase="running"``) and on a graceful stop
(``phase="exited"``), with a memory sample folded in by the heartbeat. On
the *next* boot the runtime reads the prior record and runs
:func:`classify_unclean_exit`: a record still marked ``running`` means the
previous process never reached its graceful-stop path — an unclean exit,
and (given a high last memory sample) a suspected OOM-kill.
Pure and JSON-round-trippable so both the durable store and the health
surface share one shape; the wrapper owns persistence.
Attributes:
phase: ``"running"`` | ``"draining"`` | ``"exited"`` — the last phase
the process recorded for itself.
exit_kind: ``"clean"`` | ``"unclean"`` | ``"unknown"`` — set by
:func:`classify_unclean_exit` on the *next* boot (never
``"unknown"``-treated-as-healthy downstream).
suspected_oom: ``True`` when an unclean exit coincided with a high last
memory sample (memory pressure at death).
last_mem_fraction: Last sampled memory-budget fraction in ``[0, 1]``, or
``None`` when the platform could not report it.
restart_events: Persisted monotonic-independent wall-clock timestamps of
restart-worthy boots, for :class:`PersistentRestartLoopGuard`.
"""
phase: Literal["running", "draining", "exited"] = "running"
exit_kind: Literal["clean", "unclean", "unknown"] = "unknown"
suspected_oom: bool = False
last_mem_fraction: Optional[float] = None
restart_events: List[float] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Convert to a JSON-serializable dict for the durable sentinel."""
return {
"phase": self.phase,
"exit_kind": self.exit_kind,
"suspected_oom": self.suspected_oom,
"last_mem_fraction": self.last_mem_fraction,
"restart_events": list(self.restart_events),
}
@classmethod
def from_dict(cls, data: Optional[Mapping[str, Any]]) -> "LifecycleRecord":
"""Rebuild from a persisted dict, tolerating partial/legacy shapes."""
if not isinstance(data, Mapping):
return cls()
phase = data.get("phase")
if phase not in ("running", "draining", "exited"):
phase = "running"
exit_kind = data.get("exit_kind")
if exit_kind not in ("clean", "unclean", "unknown"):
exit_kind = "unknown"
frac = data.get("last_mem_fraction")
try:
frac_val: Optional[float] = None if frac is None else float(frac)
except (TypeError, ValueError):
frac_val = None
raw_events = data.get("restart_events") or []
events: List[float] = []
if isinstance(raw_events, (list, tuple)):
for item in raw_events:
try:
events.append(float(item))
except (TypeError, ValueError):
continue
return cls(
phase=phase, # type: ignore[arg-type]
exit_kind=exit_kind, # type: ignore[arg-type]
suspected_oom=bool(data.get("suspected_oom", False)),
last_mem_fraction=frac_val,
restart_events=events,
)
def classify_unclean_exit(
prev: Optional[LifecycleRecord],
*,
oom_fraction: float = 0.9,
) -> LifecycleRecord:
"""Classify the previous boot's exit and return a fresh boot record (pure).
Given the record persisted by the *previous* process, decide whether that
process exited cleanly and stamp a new ``phase="running"`` record for *this*
boot. Side-effect free so it is provable in isolation, mirroring
:func:`classify_exit_reason`.
Rules:
* No prior record ⇒ first boot / fresh store: ``exit_kind="unknown"`` (the
caller must never treat ``"unknown"`` as healthy, but there is nothing to
resume).
* Prior ``phase == "exited"`` ⇒ the previous process reached its graceful
stop: ``exit_kind="clean"``.
* Prior ``phase in ("running", "draining")`` ⇒ the previous process died
before recording a clean exit (SIGKILL/OOM/segfault/orchestrator kill):
``exit_kind="unclean"``, and ``suspected_oom`` when its last memory sample
was at/above ``oom_fraction`` of the budget.
The returned record carries forward the prior ``restart_events`` so the
persistent restart-loop guard sees the full cross-boot history.
Args:
prev: The previous process's persisted :class:`LifecycleRecord`, or
``None`` on a fresh store.
oom_fraction: Memory-budget fraction at/above which an unclean exit is
flagged as a suspected OOM-kill.
Returns:
A fresh ``phase="running"`` :class:`LifecycleRecord` for this boot.
"""
carried = list(prev.restart_events) if prev is not None else []
if prev is None:
return LifecycleRecord(
phase="running", exit_kind="unknown", restart_events=carried
)
if prev.phase == "exited":
return LifecycleRecord(
phase="running", exit_kind="clean", restart_events=carried
)
# Still "running"/"draining" in the prior record ⇒ never reached graceful stop.
frac = prev.last_mem_fraction
suspected_oom = frac is not None and frac >= oom_fraction
return LifecycleRecord(
phase="running",
exit_kind="unclean",
suspected_oom=suspected_oom,
last_mem_fraction=frac,
restart_events=carried,
)
@runtime_checkable
class RestartStoreProtocol(Protocol):
"""Durable-store contract for cross-boot restart timestamps (Issue #4603).
The pure decision (:class:`PersistentRestartLoopGuard`) lives in core; the
wrapper injects a concrete store that reads/writes the timestamps to a small
sentinel file. Both methods are best-effort: an implementation that cannot
reach its backing store returns ``[]`` from :meth:`load_events` and silently
drops :meth:`save_events`, so the breaker degrades to in-memory behaviour
rather than crashing the gateway it protects.
"""
def load_events(self) -> List[float]:
"""Return the persisted restart timestamps (``[]`` when unavailable)."""
...
def save_events(self, events: List[float]) -> None:
"""Persist the restart timestamps (best-effort, never raises)."""
...
class PersistentRestartLoopGuard(RestartLoopGuard):
"""A :class:`RestartLoopGuard` whose events survive a process restart.
The in-memory guard is reconstructed fresh on every boot, so a persisted
turn that hard-crashes the *whole* process (OOM, segfault) can drive an
infinite ``supervisor → boot → auto-resume → crash`` loop the breaker never
sees. This subclass loads its event history from an injected
:class:`RestartStoreProtocol` at construction and flushes back on every
mutation, so the breaker observes the true cross-boot restart rate and can
trip on a crash cycle *slower* than a single in-process window.
The window defaults to one hour (vs. the in-process 60s) because a
cross-boot crash loop is paced by process (re)start latency, not by an
in-loop retry. Pure decision preserved: all persistence is delegated to the
injected store, so the trip logic remains provable in isolation.
Example::
guard = PersistentRestartLoopGuard(store, max_restarts=3,
window_seconds=3600)
if guard.record(now=time.time()):
# too many restart-interrupted boots — stop auto-resuming the turn
...
"""
def __init__(
self,
store: RestartStoreProtocol,
*,
max_restarts: int = 3,
window_seconds: float = 3600.0,
):
super().__init__(max_restarts=max_restarts, window_seconds=window_seconds)
self._store = store
try:
loaded = store.load_events()
except Exception:
loaded = []
if loaded:
self._events = [float(t) for t in loaded]
def _flush(self) -> None:
try:
self._store.save_events(list(self._events))
except Exception:
pass
def record(self, now: float) -> bool:
"""Record a restart at wall-clock ``now`` and flush to the store.
Unlike the base class this expects a *wall-clock* timestamp
(``time.time()``), because a monotonic clock resets across process
boots and would make persisted events meaningless.
"""
tripped = super().record(now)
self._flush()
return tripped
def reset(self) -> None:
"""Clear the history in memory *and* in the durable store."""
super().reset()
self._flush()
def classify_resource_pressure(
*,
mem_fraction: Optional[float] = None,
disk_free_mb: Optional[float] = None,
mem_elevated: float = 0.75,
mem_critical: float = 0.9,
disk_min_free_mb: float = 512.0,
) -> Dict[str, str]:
"""Classify coarse memory + disk pressure for the health surface (pure).
A tiny, redaction-safe classifier that folds the wrapper's ``/proc``/cgroup
memory-budget fraction and free-disk sample into a closed ``ok`` /
``elevated`` / ``critical`` / ``unknown`` vocabulary. Lives in core so the
SDK and the bot classify identically; the wrapper owns the sampling.
``unknown`` is returned when a signal is missing and — per the issue — must
never be treated as healthy by callers (it is deliberately *not* ``ok``).
Args:
mem_fraction: Anonymous-RSS / memory-budget fraction in ``[0, ∞)``, or
``None`` when the platform has no cgroup limit to report against.
disk_free_mb: Free disk headroom in MiB for the gateway's state dir, or
``None`` when it could not be sampled.
mem_elevated: Fraction at/above which memory is ``"elevated"``.
mem_critical: Fraction at/above which memory is ``"critical"``.
disk_min_free_mb: Free-MiB floor below which disk is ``"critical"``;
``"elevated"`` below twice that.
Returns:
``{"memory": <level>, "disk": <level>}`` with values drawn from the
closed vocabulary above.
"""
def _mem(frac: Optional[float]) -> str:
if frac is None:
return "unknown"
try:
f = float(frac)
except (TypeError, ValueError):
return "unknown"
if f >= mem_critical:
return "critical"
if f >= mem_elevated:
return "elevated"
return "ok"
def _disk(free_mb: Optional[float]) -> str:
if free_mb is None:
return "unknown"
try:
mb = float(free_mb)
except (TypeError, ValueError):
return "unknown"
if mb < disk_min_free_mb:
return "critical"
if mb < disk_min_free_mb * 2:
return "elevated"
return "ok"
return {"memory": _mem(mem_fraction), "disk": _disk(disk_free_mb)}
class FleetSupervisionPolicy:
"""Pure fleet-level crash-loop breaker for channel supervision (Issue #3840).
@@ -46,6 +46,7 @@ class InvocationContext:
tool_name: Optional[str] = None
model_name: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
cancel_event: Optional[Any] = None
@dataclass
@@ -53,6 +53,11 @@ class HookRegistry:
}
self._enabled = enabled
self._global_timeout = 60.0
# Guard mutation/iteration of the per-event hook lists. The default
# registry is a cross-agent process-wide singleton, so a plugin
# (un)registering a hook on one thread can race a get_hooks() iteration
# on another. RLock keeps re-entrant calls (register -> register_*) safe.
self._lock = threading.RLock()
@property
def enabled(self) -> bool:
@@ -74,7 +79,8 @@ class HookRegistry:
Returns:
The hook ID
"""
self._hooks[hook.event].append(hook)
with self._lock:
self._hooks[hook.event].append(hook)
logger.debug(f"Registered hook '{hook.name}' for event '{hook.event.value}'")
return hook.id
@@ -214,12 +220,13 @@ class HookRegistry:
Returns:
True if found and removed, False otherwise
"""
for event in HookEvent:
for i, hook in enumerate(self._hooks[event]):
if hook.id == hook_id:
self._hooks[event].pop(i)
logger.debug(f"Unregistered hook '{hook_id}'")
return True
with self._lock:
for event in HookEvent:
for i, hook in enumerate(self._hooks[event]):
if hook.id == hook_id:
self._hooks[event].pop(i)
logger.debug(f"Unregistered hook '{hook_id}'")
return True
return False
def get_hooks(
@@ -240,7 +247,11 @@ class HookRegistry:
if not self._enabled:
return []
hooks = self._hooks.get(event, [])
# Snapshot under the lock so callers never iterate the live, mutable
# list (a concurrent unregister()/clear() could otherwise raise
# "list changed size during iteration" or skip/duplicate a hook).
with self._lock:
hooks = list(self._hooks.get(event, []))
if target is None:
return [h for h in hooks if h.enabled]
@@ -258,11 +269,12 @@ class HookRegistry:
Args:
event: Optional event to clear hooks for
"""
if event is None:
for e in HookEvent:
self._hooks[e] = []
else:
self._hooks[event] = []
with self._lock:
if event is None:
for e in HookEvent:
self._hooks[e] = []
else:
self._hooks[event] = []
def list_hooks(self) -> Dict[str, List[Dict]]:
"""
@@ -290,20 +302,22 @@ class HookRegistry:
def enable_hook(self, hook_id: str) -> bool:
"""Enable a specific hook."""
for event in HookEvent:
for hook in self._hooks[event]:
if hook.id == hook_id:
hook.enabled = True
return True
with self._lock:
for event in HookEvent:
for hook in self._hooks[event]:
if hook.id == hook_id:
hook.enabled = True
return True
return False
def disable_hook(self, hook_id: str) -> bool:
"""Disable a specific hook."""
for event in HookEvent:
for hook in self._hooks[event]:
if hook.id == hook_id:
hook.enabled = False
return True
with self._lock:
for event in HookEvent:
for hook in self._hooks[event]:
if hook.id == hook_id:
hook.enabled = False
return True
return False
def set_global_timeout(self, timeout: float):
@@ -354,13 +354,18 @@ class HookRunner:
"""Execute a function hook."""
if hook.func is None:
duration = (time.time() - start_time) * 1000
# Fail closed: a hook with no callable cannot produce a verdict, so a
# gating event (BEFORE_TOOL) must block rather than silently allow.
# Without an ``output`` here ``is_blocked`` returned False and the tool
# ran — the same fail-open the exception/timeout paths already fix.
return HookExecutionResult(
hook_id=hook.id,
hook_name=hook.name or "unknown",
event=event,
success=False,
error="Hook function is None",
duration_ms=duration
duration_ms=duration,
output=HookResult(decision="deny", reason=f"Hook '{hook.name}' has no callable")
)
timeout = hook.timeout or self._default_timeout
@@ -450,6 +455,11 @@ class HookRunner:
exit_code: int
) -> HookResult:
"""Parse command output into HookResult."""
# Whether the process exited with a code we understand. Any other code
# (127 command-not-found, 126 not-executable, etc.) means the hook could
# not render a trustworthy verdict, so JSON stdout must not override it.
expected_exit = exit_code in (EXIT_CODE_SUCCESS, EXIT_CODE_BLOCKING_ERROR)
# Try to parse JSON from stdout
if stdout.strip():
try:
@@ -459,8 +469,22 @@ class HookRunner:
if isinstance(data, str):
data = json.loads(data)
decision = data.get("decision", "allow")
# Fail closed: valid JSON with an ``allow`` decision must not
# bypass an unexpected exit code. A gating hook (BEFORE_TOOL)
# that prints allow-JSON but then crashes (e.g. exit 127) has
# not actually approved the call, so deny instead of allowing.
if decision == "allow" and not expected_exit:
return HookResult(
decision="deny",
reason=(
f"Hook exited with unexpected code {exit_code} "
f"after emitting an allow decision: "
f"{stderr.strip() or 'no stderr'}"
)
)
return HookResult(
decision=data.get("decision", "allow"),
decision=decision,
reason=data.get("reason"),
modified_input=data.get("modified_input"),
additional_context=data.get("additional_context"),
@@ -481,9 +505,19 @@ class HookRunner:
reason=stderr.strip() or stdout.strip() or "Blocked by hook"
)
else:
# Fail closed: any *unexpected* exit code (e.g. 127 command-not-found,
# 126 not-executable, or any other non-zero, non-blocking code) means
# the hook could not render a verdict. Treating that as ``allow`` would
# silently bypass a gating hook (BEFORE_TOOL). Deny so the gate matches
# the fail-closed posture of the exception/timeout paths and
# GuardrailChain. Emitting a real HookResult (decision + reason) also
# keeps ``get_blocking_reason`` informative for the audit trail.
return HookResult(
decision="allow",
additional_context=f"Warning: {stderr.strip() or stdout.strip()}"
decision="deny",
reason=(
f"Hook exited with unexpected code {exit_code}: "
f"{stderr.strip() or stdout.strip() or 'no output'}"
)
)
@staticmethod
@@ -219,6 +219,10 @@ def __getattr__(name):
from .error_classifier import classify_error
_lazy_cache[name] = classify_error
return classify_error
elif name == "resolve_provider":
from .model_providers import resolve_provider
_lazy_cache[name] = resolve_provider
return resolve_provider
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -278,5 +282,7 @@ __all__ = [
"sanitize_text",
# Error Classification (G5)
"ErrorCategory",
"classify_error"
"classify_error",
# Model-name → provider-id resolution
"resolve_provider",
]
+87 -17
View File
@@ -4580,6 +4580,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
execute_tool_fn: Optional[Callable] = None,
max_tool_calls_per_turn: int = 10, # Loop guardrails
stream: bool = True,
parallel_tool_calls: bool = False,
**kwargs
) -> str:
"""Async version of get_response with identical functionality."""
@@ -4606,6 +4607,36 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
except Exception as _steer_err:
logging.debug(f"Steering drain failed (ignored): {_steer_err}")
async def _dispatch_tool_batch(dispatch_specs, iteration_index):
"""Run a batch of (function_name, arguments, tool_call_id) dispatches.
When ``parallel_tool_calls`` is set, the independent tool coroutines
are awaited concurrently via ``asyncio.gather`` (mirroring the sync
path's ``create_tool_call_executor(parallel=True)``); otherwise they
run one-at-a-time. Results are always returned in the original call
order so the existing message-building loop stays unchanged.
"""
if parallel_tool_calls and len(dispatch_specs) > 1:
# return_exceptions=True so a single failing tool does not cancel
# its still-in-flight siblings and orphan their coroutines (the
# exact leak class this PR fixes). We then re-raise the first
# error after every sibling has settled, preserving the
# sequential path's fail-fast propagation semantics.
gathered = await asyncio.gather(*(
_dispatch_async_tool(execute_tool_fn, fn, args, tc_id, iteration_index)
for (fn, args, tc_id) in dispatch_specs
), return_exceptions=True)
for item in gathered:
if isinstance(item, BaseException):
raise item
return gathered
results = []
for (fn, args, tc_id) in dispatch_specs:
results.append(await _dispatch_async_tool(
execute_tool_fn, fn, args, tc_id, iteration_index
))
return results
try:
import litellm
logging.debug(f"Getting async response from {self.model}")
@@ -4789,6 +4820,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
tool_calls = tool_calls[:remaining_calls]
logging.warning(f"Limiting batch to {remaining_calls} tool calls to stay within limit of {max_tool_calls_per_turn}.")
# Pre-resolve each tool call, then dispatch the valid ones
# as a batch (concurrently when parallel_tool_calls is set).
_parsed_calls = []
_dispatch_specs = []
for tool_call in tool_calls:
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call)
if self._tool_arguments_parse_failed(arguments):
@@ -4796,13 +4831,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
messages.append(self._tool_parse_error_message(function_name, tool_call_id))
continue
logging.debug(f"[RESPONSES_API_ASYNC] Executing tool {function_name}")
tool_result = await _dispatch_async_tool(
execute_tool_fn,
function_name,
arguments,
tool_call_id,
iteration_count,
)
_parsed_calls.append((function_name, arguments, tool_call_id))
_dispatch_specs.append((function_name, arguments, tool_call_id))
_batch_results = await _dispatch_tool_batch(_dispatch_specs, iteration_count)
for (function_name, arguments, tool_call_id), tool_result in zip(_parsed_calls, _batch_results):
tool_call_count += 1 # Increment tool call counter for guardrails
accumulated_tool_results.append(tool_result)
@@ -5041,6 +5073,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# flushed only AFTER every tool reply for this assistant
# turn, keeping all tool replies consecutive (provider contract).
_deferred_media_followups: List[Dict[str, Any]] = []
# First pass: parse/validate every call and record a plan so
# the valid dispatches can be batched (concurrently when
# parallel_tool_calls is set) while preserving call order.
_call_plan = [] # ('error', msg) | ('call', fn, args, tc_id)
_dispatch_specs = []
for tool_call in tool_calls:
# Handle both object and dict access patterns
is_ollama = self._is_ollama_provider()
@@ -5048,20 +5085,24 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if self._tool_arguments_parse_failed(arguments):
# Do NOT dispatch with {}: surface a retryable error.
messages.append(self._tool_parse_error_message(function_name, tool_call_id))
_call_plan.append(('error', self._tool_parse_error_message(function_name, tool_call_id)))
continue
# Validate and filter arguments for Ollama provider
if is_ollama and tools:
arguments = self._validate_and_filter_ollama_arguments(function_name, arguments, tools)
tool_result = await _dispatch_async_tool(
execute_tool_fn,
function_name,
arguments,
tool_call_id,
iteration_count,
)
_call_plan.append(('call', function_name, arguments, tool_call_id))
_dispatch_specs.append((function_name, arguments, tool_call_id))
_batch_results = await _dispatch_tool_batch(_dispatch_specs, iteration_count)
_result_iter = iter(_batch_results)
for _plan in _call_plan:
if _plan[0] == 'error':
messages.append(_plan[1])
continue
_, function_name, arguments, tool_call_id = _plan
tool_result = next(_result_iter)
tool_call_count += 1 # Increment tool call counter for guardrails
tool_results.append(tool_result) # Store the result
accumulated_tool_results.append(tool_result) # Accumulate across iterations
@@ -5746,10 +5787,39 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
"""Set the current agent name for token tracking."""
self.current_agent_name = agent_name
def _resolve_openai_compatible_model(self) -> str:
"""Route a bare model name through the OpenAI-compatible client.
When a custom ``base_url`` is set but the model has no ``provider/``
prefix (e.g. ``deepseek-v4-flash`` against an OpenAI-compatible host),
LiteLLM cannot infer a provider and raises "LLM Provider NOT provided".
Prefixing ``openai/`` makes LiteLLM use its OpenAI Chat Completions
client against the supplied ``base_url``. Anthropic/Gemini/Ollama models
are left untouched so their native adapters still apply.
The effective base_url is either the instance ``base_url`` or one
injected by a subscription auth provider (e.g. qwen-cli/codex resolve a
Chat-Completions host at request time). Both paths need routing so a
bare model under subscription auth does not reach LiteLLM unprefixed.
"""
model = self.model
if not isinstance(model, str) or "/" in model:
return model
effective_base_url = self.base_url
if not effective_base_url:
creds = self._resolve_subscription_creds()
if creds and getattr(creds, "base_url", None):
effective_base_url = creds.base_url
if not effective_base_url:
return model
if self._detect_provider() != "openai":
return model
return f"openai/{model}"
def _build_completion_params(self, **override_params) -> Dict[str, Any]:
"""Build parameters for litellm completion calls with all necessary config"""
params = {
"model": self.model,
"model": self._resolve_openai_compatible_model(),
}
# Add optional parameters if they exist
@@ -6061,7 +6131,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
- tools → ``tools`` (Responses API uses same function schema)
- temperature, max_tokens, etc. → direct pass-through
"""
params: Dict[str, Any] = {"model": self.model}
params: Dict[str, Any] = {"model": self._resolve_openai_compatible_model()}
# ── Extract system instructions ─────────────────────────────────
instructions = None
@@ -0,0 +1,96 @@
"""Model-name → provider-id resolution.
Maps a bare model name (e.g. ``claude-sonnet-4-6``, ``llama-3.1-70b``) to its
canonical provider id (``anthropic``, ``groq``, ...). This is the single source
of truth for provider inference so callers no longer hand-code a 3-branch
``if/elif`` that silently ignores every other vendor.
Third parties add a vendor by publishing an entry point under the
``praisonaiagents.model_providers`` group. Each entry point loads a callable
``(model_name_lowercased: str) -> bool``; the entry-point *name* is the provider
id returned on a match. Registered matchers take precedence over the built-ins,
so a plugin can also override built-in detection.
"""
from __future__ import annotations
from typing import Callable, Dict, Optional
# Built-in model-name → provider matchers. Kept intentionally small and data
# driven; extend via the entry-point group rather than editing callers.
_BUILTINS: Dict[str, Callable[[str], bool]] = {
# ``claude`` is matched as a substring so cloud-vendored forms
# (``us.anthropic.claude-3-5-sonnet``, ``anthropic.claude-...``) still
# resolve, preserving the pre-refactor behaviour that keyed off
# ``'claude' in model``.
"anthropic": lambda m: "claude" in m,
"openai": lambda m: m.startswith(("gpt-", "o1-", "o3-", "o4-", "chatgpt-")),
"google": lambda m: m.startswith(("gemini-", "gemma-")),
"groq": lambda m: m.startswith(("llama-", "mixtral-", "gemma2-")) and "groq" in m,
"cohere": lambda m: m.startswith(("command-", "c4ai-")),
"mistral": lambda m: m.startswith(("mistral-", "codestral-", "open-mistral-", "open-mixtral-")),
"ollama": lambda m: m.startswith("ollama/"),
"deepseek": lambda m: m.startswith("deepseek-"),
"xai": lambda m: m.startswith("grok-"),
"perplexity": lambda m: m.startswith(("pplx-", "sonar-")),
}
def _entry_point_matchers():
"""Yield (provider_id, matcher) pairs registered by third-party plugins.
Isolated so a broken plugin cannot take down provider resolution: any
import/metadata failure yields nothing and falls back to built-ins.
"""
try:
from importlib.metadata import entry_points
except Exception: # pragma: no cover - importlib.metadata always present on 3.8+
return
try:
eps = entry_points(group="praisonaiagents.model_providers")
except TypeError:
# Python 3.9 returns a dict-like object without the group kwarg.
eps = entry_points().get("praisonaiagents.model_providers", [])
except Exception:
return
for ep in eps:
try:
matcher = ep.load()
except Exception:
continue
yield ep.name, matcher
def resolve_provider(model_name: str) -> Optional[str]:
"""Return the canonical provider id for ``model_name``, or None.
Precedence:
1. Explicit litellm-style prefix wins (``openai/gpt-4o`` -> ``openai``).
2. Entry-point registered matchers (third-party vendors / overrides).
3. Built-in matchers.
"""
if not model_name:
return None
# 1. Explicit provider prefix.
if "/" in model_name:
return model_name.split("/", 1)[0].lower()
lower = model_name.lower()
# 2. Third-party / override matchers.
for provider, matcher in _entry_point_matchers():
try:
if matcher(lower):
return provider
except Exception:
continue
# 3. Built-ins.
for provider, matcher in _BUILTINS.items():
if matcher(lower):
return provider
return None
@@ -29,7 +29,7 @@ Memory Providers:
- RulesManager: Rules/instructions file management
- AutoMemory: Automatic memory extraction from conversations
- WorkflowManager: Multi-step workflow execution
- HooksManager: Pre/post operation hooks
- HooksManager: Standalone, opt-in pre/post operation hooks (call .execute() yourself; not auto-wired into the agent pipeline)
- DocsManager: Documentation context management
- MCPConfigManager: MCP server configuration management
"""
@@ -4,6 +4,17 @@ Cascade Hooks System for PraisonAI Agents.
Provides event hooks similar to Windsurf's Cascade Hooks,
allowing custom actions before/after agent operations.
.. note::
``HooksManager`` is a **standalone, opt-in utility**: it is NOT auto-wired
into the agent/tool execution pipeline. You must construct it and call
:meth:`HooksManager.execute` yourself at the point where you want a hook to
run (e.g. in a custom tool or wrapper). For hooks that fire automatically
around agent tool/LLM calls, use the live ``praisonaiagents.hooks`` package
instead: register handlers on a ``HookRegistry`` (e.g. via
``@registry.on(HookEvent.BEFORE_TOOL)``) and pass that registry to the
``Agent`` through its ``hooks`` argument (the ``Agent`` builds the
``HookRunner`` internally).
Features:
- Pre/post hooks for read, write, command, prompt operations
- JSON configuration (.praisonai/hooks.json)
@@ -87,6 +98,12 @@ class HooksManager:
Hooks are configured in .praisonai/hooks.json and can execute
scripts before/after agent operations.
This is a standalone, opt-in utility. It does not automatically
intercept agent file/command/prompt operations; the caller must
invoke :meth:`execute` at the desired point. For hooks that fire
automatically around agent tool/LLM calls, use the
``praisonaiagents.hooks`` package instead.
Example:
```python
hooks = HooksManager(workspace_path="/path/to/project")
@@ -591,7 +591,7 @@ Return empty arrays if nothing is found for a category."""
for improvement in extracted.get("improvements", []):
if improvement and "improvements" in self._stores:
self.capture_improvement(improvement, source="auto_extraction")
self.capture_improvement(improvement, area="auto_extraction")
stored["improvements"] += 1
return {
@@ -623,7 +623,7 @@ Return empty arrays if nothing is found for a category."""
Dictionary with extracted learnings
"""
if not messages:
return {"persona": [], "insights": [], "patterns": [], "stored": {}}
return {"persona": [], "insights": [], "patterns": [], "improvements": [], "stored": {}}
# Build conversation text
conversation_text = "\n".join([
@@ -640,12 +640,14 @@ Extract the following (if present):
1. USER PREFERENCES: Things the user likes, dislikes, prefers, or their style
2. INSIGHTS: Observations about the user's domain, work, or context
3. PATTERNS: Recurring behaviors or request patterns
4. IMPROVEMENTS: Concrete proposals to improve future responses
Return JSON:
{{
"persona": ["preference 1", "preference 2"],
"insights": ["insight 1", "insight 2"],
"patterns": ["pattern 1", "pattern 2"]
"patterns": ["pattern 1", "pattern 2"],
"improvements": ["improvement 1", "improvement 2"]
}}
Only include items that are clearly evident from the conversation.
@@ -676,9 +678,9 @@ Return empty arrays if nothing is found for a category."""
elif isinstance(response, dict):
extracted = response
else:
extracted = {"persona": [], "insights": [], "patterns": []}
extracted = {"persona": [], "insights": [], "patterns": [], "improvements": []}
stored = {"persona": 0, "insights": 0, "patterns": 0}
stored = {"persona": 0, "insights": 0, "patterns": 0, "improvements": 0}
for preference in extracted.get("persona", []):
if preference and "persona" in self._stores:
@@ -695,10 +697,16 @@ Return empty arrays if nothing is found for a category."""
self.capture_pattern(pattern, pattern_type="auto_extracted")
stored["patterns"] += 1
for improvement in extracted.get("improvements", []):
if improvement and "improvements" in self._stores:
self.capture_improvement(improvement, area="auto_extraction")
stored["improvements"] += 1
return {
"persona": extracted.get("persona", []),
"insights": extracted.get("insights", []),
"patterns": extracted.get("patterns", []),
"improvements": extracted.get("improvements", []),
"stored": stored,
}
@@ -79,11 +79,17 @@ def is_due(
try:
target = datetime.fromisoformat(sched.at)
if target.tzinfo is None:
target = target.replace(
tzinfo=resolve_schedule_timezone(
sched.tz or default_timezone
)
)
# A naive ``at`` reflects the user's wall clock (they typed it
# from local time), so interpret it in the schedule's timezone:
# the explicit tz / PRAISONAI_SCHEDULE_TIMEZONE when set,
# otherwise the local zone rather than UTC — a naive string in a
# non-UTC zone would otherwise fire an hour (or more) off.
tz = sched.tz or default_timezone
if tz or os.environ.get("PRAISONAI_SCHEDULE_TIMEZONE"):
tzinfo = resolve_schedule_timezone(tz)
else:
tzinfo = datetime.now().astimezone().tzinfo
target = target.replace(tzinfo=tzinfo)
# Evaluate against the caller-supplied ``now`` (not wall-clock) so
# one-shot jobs are deterministic and consistent with every/cron.
return now >= target.timestamp()
@@ -15,6 +15,10 @@ Supported formats:
"cron:0 7 * * *" → cron expression
"at:2026-03-01T09:00:00" → one-shot ISO timestamp
"in 20 minutes" → one-shot ~20min from now
"at 9am" → one-shot next 09:00
"every day at 9am" → daily at 09:00 (cron)
"weekdays at 9am" → Mon-Fri at 09:00 (cron)
"every monday 9am" → day-of-week at clock time (cron)
"""
import re
@@ -44,6 +48,138 @@ _UNIT_MULTIPLIER = {
"h": 3600, "hr": 3600, "hour": 3600, "hours": 3600,
}
# Day-of-week names → cron day field (0 or 7 = Sunday). Aliases map to ranges.
_NL_DOW = {
"sunday": "0", "sun": "0",
"monday": "1", "mon": "1",
"tuesday": "2", "tue": "2", "tues": "2",
"wednesday": "3", "wed": "3",
"thursday": "4", "thu": "4", "thur": "4", "thurs": "4",
"friday": "5", "fri": "5",
"saturday": "6", "sat": "6",
"weekday": "1-5", "weekdays": "1-5",
"weekend": "0,6", "weekends": "0,6",
}
# Clock time, e.g. "9am", "9:30pm", "17:00", "9 am".
_CLOCK_RE = re.compile(
r"(?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?\s*(?P<ampm>am|pm)?",
re.IGNORECASE,
)
def _parse_clock(text: str):
"""Parse a clock-time fragment into ``(hour, minute)`` or ``None``."""
m = _CLOCK_RE.fullmatch(text.strip())
if not m:
return None
hour = int(m.group("hour"))
minute = int(m.group("minute") or 0)
ampm = (m.group("ampm") or "").lower()
if ampm:
if hour < 1 or hour > 12:
return None
if ampm == "pm" and hour != 12:
hour += 12
elif ampm == "am" and hour == 12:
hour = 0
if hour > 23 or minute > 59:
return None
return hour, minute
def _natural_to_cron(expr: str):
"""Convert a natural-language schedule to a 5-field cron string.
Returns the cron string for recurring day-of-week / daily clock times,
or ``None`` when the expression is not a recurring NL form (callers then
fall through to the one-shot / interval / cron paths).
"""
lower = expr.lower().strip()
lower = re.sub(r"^every\s+", "", lower)
lower = re.sub(r"(?:^|\s)(?:at|on)\s+", " ", lower)
lower = re.sub(r"\s+", " ", lower).strip()
if not lower:
return None
# Drop filler words that carry no scheduling meaning once "every"/"at"/"on"
# have been stripped ("every day at 9am" -> daily; "each morning" unsupported).
_FILLER = {"day", "daily", "the", "of", "a"}
tokens = [t for t in lower.split(" ") if t and t not in _FILLER]
dow_parts = []
clock = None
i = 0
while i < len(tokens):
tok = tokens[i]
# A comma-joined list of day names, e.g. "mon,wed,fri".
if "," in tok and all(d in _NL_DOW for d in tok.split(",") if d):
for d in tok.split(","):
if d:
dow_parts.append(_NL_DOW[d])
i += 1
continue
if tok in _NL_DOW:
dow_parts.append(_NL_DOW[tok])
i += 1
continue
# Otherwise attempt a clock time, possibly split across two tokens
# ("9 am", "9:30 pm").
joined = tok
if i + 1 < len(tokens) and tokens[i + 1] in ("am", "pm"):
joined = tok + tokens[i + 1]
i += 1
parsed = _parse_clock(joined)
if parsed is None:
return None
if clock is not None:
return None
clock = parsed
i += 1
if clock is None:
return None
hour, minute = clock
dow = ",".join(dow_parts) if dow_parts else "*"
return f"{minute} {hour} * * {dow}"
def _clock_to_next_at(hour: int, minute: int, tz: str | None) -> str:
"""Return the next ISO timestamp for the given clock time (one-shot).
The clock time denotes local time in the schedule's timezone (``tz`` or
the process default), so ``at 9am`` fires at 09:00 in that zone rather
than 09:00 UTC. Falls back to UTC when the timezone cannot be resolved.
"""
try:
from .due import resolve_schedule_timezone
tzinfo = resolve_schedule_timezone(tz)
except Exception:
tzinfo = timezone.utc
now = datetime.now(tzinfo)
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if target <= now:
target += timedelta(days=1)
return target.isoformat()
def _cron_schedule(cron_expr: str, tz: str | None) -> Schedule:
"""Build a cron Schedule, refusing if nothing can evaluate it.
``is_due()`` returns False for a cron job when ``croniter`` is missing --
a log line, no error, a job that never runs. A schedule the runtime
cannot evaluate must be rejected here, where the user can still see the
answer, not accepted and ignored. Every natural-language recurring form
("every day at 9am") arrives through this path.
"""
try:
import croniter # noqa: F401 # type: ignore[import-untyped]
except ImportError as exc:
raise ValueError(
f"Schedule {cron_expr!r} needs the cron engine, which is not "
"installed. Install with: pip install croniter"
) from exc
return Schedule(kind="cron", cron_expr=cron_expr, tz=tz)
def parse_schedule(expr: str, tz: str | None = None) -> Schedule:
"""Parse a schedule expression string into a ``Schedule`` object.
@@ -68,7 +204,7 @@ def parse_schedule(expr: str, tz: str | None = None) -> Schedule:
cron_expr = expr[5:].strip()
if not cron_expr:
raise ValueError("Empty cron expression after 'cron:' prefix")
return Schedule(kind="cron", cron_expr=cron_expr, tz=tz)
return _cron_schedule(cron_expr, tz)
# At prefix (ISO timestamp)
if lower.startswith("at:"):
@@ -102,7 +238,25 @@ def parse_schedule(expr: str, tz: str | None = None) -> Schedule:
except ValueError:
pass
# Natural-language clock-time / day-of-week expressions:
# "at 9am" → one-shot next 09:00
# "every day at 9am" → daily at 09:00 (cron)
# "weekdays at 9am" → Mon-Fri at 09:00 (cron)
# "every monday 9am" → day-of-week at clock time (cron)
# A bare clock time with no day / "every" is treated as a one-shot; any
# recurring form ("every ...", "daily ...", a day-of-week) maps to cron.
cron_expr = _natural_to_cron(expr)
if cron_expr is not None:
has_dow = cron_expr.rsplit(" ", 1)[-1] != "*"
recurring = has_dow or bool(re.match(r"(?i)^\s*(every|daily|each)\b", expr))
if recurring:
return _cron_schedule(cron_expr, tz)
minute, hour = cron_expr.split(" ")[:2]
at = _clock_to_next_at(int(hour), int(minute), tz)
return Schedule(kind="at", at=at, tz=tz)
raise ValueError(
f"Unrecognised schedule expression: {expr!r}. "
"Use 'hourly', 'daily', '*/30m', 'cron:EXPR', 'at:ISO', or 'in N minutes'."
"Use 'hourly', 'daily', '*/30m', 'cron:EXPR', 'at:ISO', 'in N minutes', "
"'at 9am', 'every day at 9am', 'weekdays at 9am', or 'every monday 9am'."
)
@@ -85,7 +85,19 @@ class Session:
timeout: HTTP timeout for remote agent calls (default: 30 seconds)
session_ttl: Time-to-live in seconds after which session expires
"""
self.session_id = session_id or str(uuid.uuid4())[:8]
# An explicit session_id is adopted as-is (needed to resume a prior
# session). An auto-generated one uses 16 hex chars / 64 bits (vs the
# old 8 chars / 32 bits, which hit a 50% birthday collision at ~77k IDs)
# and, since its directory is a security boundary between sessions, is
# regenerated if a directory already exists for it - mirroring the
# retry-on-collision loop the endpoint-path generator already uses.
if session_id is not None:
self.session_id = session_id
else:
self.session_id = uuid.uuid4().hex[:16]
if agent_url is None:
while self._get_session_dir().exists():
self.session_id = uuid.uuid4().hex[:16]
self.user_id = user_id or "default_user"
self.agent_url = agent_url
self.timeout = timeout
@@ -969,9 +969,16 @@ class EditTools:
*** Delete File: path/to/file
``Update File`` may contain multiple ``@@`` hunks. Returns a list of
dicts: ``{"op", "path", ...}``. Raises ``ValueError`` on malformed
input.
*** Move File: old/path -> new/path
@@ (optional hunks; rename + edit in one op)
<old block>
===
<new block>
``Update File`` and ``Move File`` may contain multiple ``@@`` hunks.
Returns a list of dicts: ``{"op", "path", ...}`` (a move carries
``{"op": "move", "src", "dst", "hunks"}``). Raises ``ValueError`` on
malformed input.
"""
lines = patch.splitlines()
ops = []
@@ -983,7 +990,8 @@ class EditTools:
def _header(line):
for op, prefix in (("add", "*** Add File:"),
("update", "*** Update File:"),
("delete", "*** Delete File:")):
("delete", "*** Delete File:"),
("move", "*** Move File:")):
if line.strip().startswith(prefix):
return op, line.split(":", 1)[1].strip()
return None, None
@@ -992,6 +1000,26 @@ class EditTools:
"""A line that terminates a body/hunk: a header or a sentinel."""
return _header(line)[0] is not None or line.strip() in _SENTINELS
def _parse_hunks(idx):
"""Consume ``@@`` hunks starting at ``idx``; return (hunks, idx)."""
hunks = []
while idx < n and not _is_boundary(lines[idx]):
if lines[idx].strip().startswith("@@"):
idx += 1
old_block, new_block = [], []
in_new = False
while idx < n and not lines[idx].strip().startswith("@@") and not _is_boundary(lines[idx]):
if lines[idx].strip() == "===":
in_new = True
idx += 1
continue
(new_block if in_new else old_block).append(lines[idx])
idx += 1
hunks.append(("\n".join(old_block), "\n".join(new_block)))
else:
idx += 1
return hunks, idx
while i < n:
line = lines[i]
if not line.strip() or line.strip() in _SENTINELS:
@@ -1010,35 +1038,37 @@ class EditTools:
i += 1
ops.append({"op": "add", "path": path, "content": "\n".join(body)})
elif op == "update":
hunks = []
while i < n and not _is_boundary(lines[i]):
if lines[i].strip().startswith("@@"):
i += 1
old_block, new_block = [], []
in_new = False
while i < n and not lines[i].strip().startswith("@@") and not _is_boundary(lines[i]):
if lines[i].strip() == "===":
in_new = True
i += 1
continue
(new_block if in_new else old_block).append(lines[i])
i += 1
hunks.append(("\n".join(old_block), "\n".join(new_block)))
else:
i += 1
hunks, i = _parse_hunks(i)
ops.append({"op": "update", "path": path, "hunks": hunks})
elif op == "move":
# Payload is "<old> -> <new>"; the separator is a literal
# " -> " so paths containing "->" without surrounding spaces
# are unaffected.
if "->" not in path:
raise ValueError(
f"Malformed Move File header (expected '<old> -> <new>'): {path!r}")
src, dst = path.split("->", 1)
src, dst = src.strip(), dst.strip()
if not src or not dst:
raise ValueError(
f"Malformed Move File header (empty source or destination): {path!r}")
hunks, i = _parse_hunks(i)
ops.append({"op": "move", "src": src, "dst": dst, "hunks": hunks})
return ops
@require_approval(risk_level="high")
def apply_patch(self, patch: str, force: bool = False) -> str:
"""Apply a structured multi-file patch atomically.
The patch may Add, Update, and Delete multiple files in a single
call. All operations are validated first; only if every operation
is valid are the changes committed to disk. The commit phase stages
writes/deletes and rolls them back if any step fails, so on any error
the filesystem is left in its original state. BOM and CRLF line
endings are preserved on Update, matching ``edit_file``.
The patch may Add, Update, Delete, and Move/rename multiple files in a
single call. A ``*** Move File: <old> -> <new>`` operation renames a
file (optionally applying ``@@`` hunks so it is renamed *and* edited in
one atomic step) without re-transmitting the whole body. All
operations are validated first; only if every operation is valid are
the changes committed to disk. The commit phase stages
writes/deletes/moves and rolls them back if any step fails, so on any
error the filesystem is left in its original state. BOM and CRLF line
endings are preserved on Update and Move, matching ``edit_file``.
Every affected path is locked for the duration of validate+commit, so
a concurrent edit/write to any of those files serialises rather than
@@ -1066,8 +1096,16 @@ class EditTools:
# Acquire per-path locks for every affected file up front, in a
# stable (sorted) order to avoid deadlock when two patches share
# files. Held across validate+commit so no concurrent edit/write
# can interleave with this atomic patch.
lock_paths = sorted({self._validate_path(op["path"]) for op in ops})
# can interleave with this atomic patch. A move locks both its
# source and destination so the rename is race-safe on both ends.
affected = set()
for op in ops:
if op["op"] == "move":
affected.add(self._validate_path(op["src"]))
affected.add(self._validate_path(op["dst"]))
else:
affected.add(self._validate_path(op["path"]))
lock_paths = sorted(affected)
with contextlib.ExitStack() as stack:
for lp in lock_paths:
stack.enter_context(_file_locks.get_lock(lp))
@@ -1083,18 +1121,97 @@ class EditTools:
try:
# Phase 1: validate every operation and compute new content.
# Each planned entry: (kind, safe_path, display, encoded_bytes,
# diff, content_hash). encoded_bytes is None for deletes.
# diff, content_hash, src_path). encoded_bytes is None for deletes;
# src_path is the source path for a move (None otherwise), so the
# commit phase can remove the origin after writing the destination.
planned = []
# Track output paths produced within this transaction so two
# operations cannot silently target the same destination (the
# later commit would clobber the earlier result while reporting
# success). Each op validates against the *initial* filesystem, so
# this cross-op guard is what prevents intra-patch conflicts.
produced = {}
consumed = {} # paths removed by a move/delete within this patch
def _claim_output(path, display):
prior = produced.get(path)
if prior is not None:
return (f"Error: Conflicting operations target the same path "
f"'{display}' (also produced by '{prior}')")
produced[path] = display
return None
for op in ops:
if op["op"] == "move":
src_path = self._validate_path(op["src"])
dst_path = self._validate_path(op["dst"])
if src_path == dst_path:
return (f"Error: Cannot move '{op['src']}' to '{op['dst']}': "
f"source and destination are the same path")
if not os.path.exists(src_path):
return f"Error: Cannot move '{op['src']}': file not found"
# The destination must be free both on disk and within this
# transaction (unless a prior op in this patch removed it).
if os.path.exists(dst_path) and dst_path not in consumed:
return (f"Error: Cannot move '{op['src']}' to '{op['dst']}': "
f"destination already exists")
conflict = _claim_output(dst_path, f"{op['src']} -> {op['dst']}")
if conflict:
return conflict
# Read in binary to preserve BOM/CRLF like update does.
with open(src_path, 'rb') as f:
raw_bytes = f.read()
try:
original, bom = self._decode_with_bom(raw_bytes)
except ValueError as e:
return f"Error: Cannot move '{op['src']}': {e}"
original_hash = self._compute_content_hash(original)
# Automatic staleness guard on the source, mirroring update.
if not force:
recorded = _file_locks.get_read_hash(src_path)
if recorded is not None and recorded != original_hash:
return (f"Error: Cannot move '{op['src']}': file changed "
f"since it was read - re-read before editing "
f"(or pass force=True to override).")
line_ending = self._detect_line_ending(original)
updated = original
for old_block, new_block in op["hunks"]:
if not old_block:
return f"Error: Empty hunk in move for '{op['src']}'"
spans = self._find_spans(updated, old_block)
if not spans:
preview = old_block[:50] + ("..." if len(old_block) > 50 else "")
return (f"Error: Hunk not found in '{op['src']}': '{preview}'")
if len(spans) > 1:
preview = old_block[:30] + ("..." if len(old_block) > 30 else "")
return (f"Error: Ambiguous hunk in '{op['src']}': '{preview}' "
f"matches {len(spans)} locations")
start, end = spans[0]
updated = updated[:start] + new_block + updated[end:]
encoded = self._encode_preserving(updated, line_ending, bom)
on_disk_form = encoded[len(bom):].decode('utf-8')
display = f"{op['src']} -> {op['dst']}"
planned.append(("move", dst_path, display, encoded,
self._render_diff(original, updated, display),
self._compute_content_hash(on_disk_form),
src_path))
# The source path is now free for a later op in this patch.
consumed[src_path] = display
produced.pop(src_path, None)
continue
safe_path = self._validate_path(op["path"])
if op["op"] == "add":
if os.path.exists(safe_path):
if os.path.exists(safe_path) and safe_path not in consumed:
return f"Error: Cannot add '{op['path']}': file already exists"
conflict = _claim_output(safe_path, op["path"])
if conflict:
return conflict
encoded = op["content"].encode('utf-8')
planned.append(("add", safe_path, op["path"], encoded,
self._render_diff("", op["content"], op["path"]),
self._compute_content_hash(op["content"])))
self._compute_content_hash(op["content"]), None))
elif op["op"] == "delete":
if not os.path.exists(safe_path):
@@ -1114,11 +1231,16 @@ class EditTools:
return (f"Error: Cannot delete '{op['path']}': file changed "
f"since it was read - re-read before editing "
f"(or pass force=True to override).")
planned.append(("delete", safe_path, op["path"], None, "", None))
planned.append(("delete", safe_path, op["path"], None, "", None, None))
consumed[safe_path] = op["path"]
produced.pop(safe_path, None)
elif op["op"] == "update":
if not os.path.exists(safe_path):
return f"Error: Cannot update '{op['path']}': file not found"
conflict = _claim_output(safe_path, op["path"])
if conflict:
return conflict
# Read in binary to preserve BOM/CRLF like edit_file does.
with open(safe_path, 'rb') as f:
raw_bytes = f.read()
@@ -1157,7 +1279,7 @@ class EditTools:
on_disk_form = encoded[len(bom):].decode('utf-8')
planned.append(("update", safe_path, op["path"], encoded,
self._render_diff(original, updated, op["path"]),
self._compute_content_hash(on_disk_form)))
self._compute_content_hash(on_disk_form), None))
# Phase 2: commit all operations atomically. Writes are staged to
# temp files in the same directory and swapped in with os.replace;
@@ -1167,20 +1289,77 @@ class EditTools:
messages = []
applied = [] # rollback log: (action, *paths)
tmp_suffix = ".praison_patch_tmp"
def _free_temp_path(base: str) -> str:
"""Return a staging path derived from ``base`` that does not
collide with an existing file, so move/write staging never
truncates or later deletes an unrelated user file that happens
to sit at the default ``<path>.praison_patch_tmp`` name."""
candidate = base + tmp_suffix
counter = 0
while os.path.lexists(candidate):
counter += 1
candidate = f"{base}{tmp_suffix}.{counter}"
return candidate
try:
for kind, safe_path, display, encoded, diff, content_hash in planned:
for kind, safe_path, display, encoded, diff, content_hash, src_path in planned:
if kind == "delete":
backup = safe_path + tmp_suffix
backup = _free_temp_path(safe_path)
os.replace(safe_path, backup)
applied.append(("delete", safe_path, backup))
self._file_cache.pop(safe_path, None)
_file_locks.clear_read_hash(safe_path)
messages.append(f"Deleted {display}")
elif kind == "move":
# Write the (optionally edited) content to the
# destination, then rename the source aside so a
# failure can restore it. ``safe_path`` is the
# validated destination; the destination is known not
# to exist (checked in phase 1).
os.makedirs(os.path.dirname(safe_path) or ".", exist_ok=True)
tmp_path = _free_temp_path(safe_path)
src_backup = _free_temp_path(src_path)
try:
with open(tmp_path, 'wb') as f:
f.write(encoded)
os.replace(tmp_path, safe_path)
os.replace(src_path, src_backup)
except Exception:
if os.path.exists(tmp_path):
try:
os.remove(tmp_path)
except OSError:
pass
# If the destination was already written but the
# source rename failed, drop the destination so the
# partial move is not left behind.
if os.path.exists(safe_path):
try:
os.remove(safe_path)
except OSError:
pass
raise
# Capture the source's recorded read-hash before we
# clear it, so a later rollback can restore it and keep
# the staleness guard armed on the restored source.
prev_src_hash = _file_locks.get_read_hash(src_path)
applied.append(("move", safe_path, src_path, src_backup,
prev_src_hash))
self._file_cache.pop(src_path, None)
_file_locks.clear_read_hash(src_path)
self._file_cache[safe_path] = content_hash
_file_locks.record_read_hash(safe_path, content_hash)
if self._post_edit_format != "off":
if self._apply_post_edit_format(safe_path):
self._refresh_hash_after_format(safe_path)
diagnostics = self._run_diagnostics(safe_path, display)
messages.append(f"Renamed {display}\n{diff}{diagnostics}")
else:
os.makedirs(os.path.dirname(safe_path) or ".", exist_ok=True)
tmp_path = safe_path + tmp_suffix
tmp_path = _free_temp_path(safe_path)
existed = os.path.exists(safe_path)
backup = safe_path + ".praison_patch_bak" if existed else None
backup = _free_temp_path(safe_path + ".praison_patch_bak") if existed else None
try:
with open(tmp_path, 'wb') as f:
f.write(encoded)
@@ -1222,13 +1401,31 @@ class EditTools:
os.replace(backup, target)
elif os.path.exists(target):
os.remove(target)
elif action == "move":
# entry = ("move", dst, src, src_backup, prev_src_hash):
# drop the freshly-written destination, restore the
# source, and re-arm its staleness guard so a later
# edit still detects external modifications.
src, src_backup = entry[2], entry[3]
prev_src_hash = entry[4] if len(entry) > 4 else None
if os.path.exists(target):
os.remove(target)
os.replace(src_backup, src)
_file_locks.clear_read_hash(target)
self._file_cache.pop(target, None)
if prev_src_hash is not None:
_file_locks.record_read_hash(src, prev_src_hash)
self._file_cache[src] = prev_src_hash
except OSError:
logger.error("Rollback failed for %s", target)
raise
# Success: discard delete backups and write backups.
# Success: discard delete/write backups and move source backups.
for entry in applied:
action, backup = entry[0], entry[2]
if entry[0] == "move":
backup = entry[3]
else:
backup = entry[2]
if backup is not None and os.path.exists(backup):
try:
os.remove(backup)
@@ -1377,12 +1574,14 @@ def edit_file(filepath: str, old_string: str, new_string: str,
@require_approval(risk_level="high")
def apply_patch(patch: str, force: bool = False) -> str:
"""Apply a structured multi-file patch atomically (Add/Update/Delete).
"""Apply a structured multi-file patch atomically (Add/Update/Delete/Move).
Args:
patch: Structured patch text with ``*** Add File:``,
``*** Update File:`` and ``*** Delete File:`` sections.
force: Bypass the automatic staleness guard on Update operations.
``*** Update File:``, ``*** Delete File:`` and
``*** Move File: <old> -> <new>`` sections. A Move may carry
optional ``@@`` hunks to rename and edit a file in one operation.
force: Bypass the automatic staleness guard on Update/Move operations.
Returns:
Combined success message with diffs or an error description.
@@ -63,7 +63,8 @@ class AgentState:
learn_manager: Optional[Any] = None
previous_tool_results: List[Any] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
cancel_event: Optional[Any] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for Injected[dict] usage."""
return {
@@ -74,6 +75,7 @@ class AgentState:
'last_agent_message': self.last_agent_message,
'previous_tool_results': self.previous_tool_results,
'metadata': self.metadata,
'cancel_event': self.cancel_event,
}
@@ -33,6 +33,12 @@ class _StreamDrainTimeout(Exception):
process.
"""
class _CommandCancelled(Exception):
"""Raised when a cooperative cancellation signal fires while the child
process is still running, so the caller kills the process group and reports
an ``interrupted`` outcome instead of waiting for the timeout."""
class ShellTools:
"""Tools for executing shell commands safely."""
@@ -125,11 +131,17 @@ class ShellTools:
text=True
)
# Observe the agent's cooperative cancellation signal (when running
# inside an agent tool call) so an interrupt (/stop, Ctrl-C) aborts
# this subprocess promptly instead of waiting for the timeout. When
# run standalone the event is None and behaviour is unchanged.
cancel_event = self._current_cancel_event()
try:
# Read stdout/stderr incrementally so a progress channel (when
# active) can surface output live while the command runs. Falls
# back to fully-buffered behaviour when no sink is listening.
stdout, stderr = self._communicate_streaming(process, timeout)
stdout, stderr = self._communicate_streaming(process, timeout, cancel_event)
# Handle over-budget output. Rather than permanently discarding
# the middle (often where the real error lives), spill the full
@@ -154,6 +166,20 @@ class ShellTools:
result['stderr_artifact'] = stderr_path
return result
except _CommandCancelled:
# 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
}
except _StreamDrainTimeout:
# The direct child already exited; only an inherited pipe kept a
# reader blocked. Report a timeout but do NOT kill — the direct
@@ -169,20 +195,7 @@ class ShellTools:
except subprocess.TimeoutExpired:
# Kill process on timeout
try:
import psutil
parent = psutil.Process(process.pid)
children = parent.children(recursive=True)
for child in children:
child.kill()
parent.kill()
except ImportError:
# Fallback: kill without psutil
process.kill()
except psutil.NoSuchProcess:
# Process already gone — nothing to kill.
pass
self._kill_process_tree(process)
return {
'stdout': '',
'stderr': f'Command timed out after {timeout} seconds',
@@ -202,7 +215,48 @@ class ShellTools:
'execution_time': 0
}
def _communicate_streaming(self, process, timeout):
def _current_cancel_event(self):
"""Return the agent's cooperative cancellation Event, if any.
Sourced from the injected AgentState set by the agent tool-execution
loop. Returns ``None`` when the tool runs outside an agent (standalone),
keeping behaviour and imports lazy with zero overhead.
"""
try:
from .injected import get_current_state
state = get_current_state()
except Exception:
return None
if state is None:
return None
return getattr(state, 'cancel_event', None)
def _kill_process_tree(self, process):
"""Kill a child process and its descendants (best-effort).
Uses psutil to reach the whole tree when available, falling back to
``process.kill()`` otherwise. Safe to call if the process already exited.
"""
try:
import psutil
parent = psutil.Process(process.pid)
children = parent.children(recursive=True)
for child in children:
try:
child.kill()
except psutil.NoSuchProcess:
pass
parent.kill()
except ImportError:
try:
process.kill()
except Exception:
pass
except psutil.NoSuchProcess:
# Process already gone — nothing to kill.
pass
def _communicate_streaming(self, process, timeout, cancel_event=None):
"""Read stdout/stderr line-buffered, emitting live progress.
Reads both streams concurrently in background threads so a line on
@@ -273,16 +327,38 @@ class ShellTools:
t_err.start()
deadline = None if timeout is None else time.monotonic() + timeout
try:
process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
# 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
if cancel_event is None:
try:
process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
# 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
else:
# Poll in short slices so a cooperative interrupt aborts the child
# promptly (within the poll interval) rather than only on timeout.
_POLL = 0.1
while True:
if cancel_event.is_set():
stop_emitting.set()
raise _CommandCancelled()
if deadline is not None:
remaining = deadline - time.monotonic()
if remaining <= 0:
stop_emitting.set()
raise subprocess.TimeoutExpired(process.args, timeout)
wait_slice = min(_POLL, remaining)
else:
wait_slice = _POLL
try:
process.wait(timeout=wait_slice)
break
except subprocess.TimeoutExpired:
continue
# Wait for the readers to drain the pipe buffers before returning,
# preserving subprocess.communicate()'s "all captured output" semantics.
+7 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.7.2"
version = "1.7.3"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -17,7 +17,12 @@ dependencies = [
"openai>=1.68.2",
"posthog>=3.0.0",
"PyYAML>=6.0",
"aiohttp>=3.8.0"
"aiohttp>=3.8.0",
# The scheduler converts every natural-language recurring schedule
# ("every day at 9am", "weekdays at 9am") to cron; without this engine
# those jobs are accepted and silently never fire. Pure Python; pulls
# only python-dateutil.
"croniter>=6.0"
]
[project.optional-dependencies]
@@ -1205,6 +1205,12 @@ steps:
researcher = parser._agents.get('researcher')
assert researcher is not None
assert hasattr(researcher, '_yaml_max_rpm')
assert researcher._yaml_max_rpm == 10
# YAML max_rpm must wire a live RateLimiter (not just store metadata).
from praisonaiagents.llm.rate_limiter import RateLimiter
assert isinstance(researcher._rate_limiter, RateLimiter)
assert researcher._rate_limiter.requests_per_minute == 10
assert researcher.max_rpm == 10
def test_agent_with_max_execution_time(self):
"""Test that agents can have max_execution_time for timeout."""
@@ -118,6 +118,77 @@ class TestInterruptController:
assert not controller.is_set()
assert controller.reason is None
def test_event_cleared_after_interrupted_turn_ends(self):
"""A stale interrupt must not carry into the next turn's tools.
Regression: the ``event`` (exposed to running tool bodies) previously
stayed set after an interrupted turn, so a reused controller aborted
subprocesses launched by the NEXT turn. Ending the interrupted turn now
clears the underlying flag so a fresh turn starts uninterrupted.
"""
controller = InterruptController()
turn = controller._begin_turn()
controller.request("user_stop")
assert controller.event.is_set()
assert controller._turn_is_cancelled(turn)
# The interrupted turn ends without an explicit clear().
controller._end_turn(turn)
assert not controller.event.is_set()
assert controller.reason is None
# A brand-new turn must NOT be seen as cancelled.
next_turn = controller._begin_turn()
assert not controller.event.is_set()
assert not controller._turn_is_cancelled(next_turn)
controller._end_turn(next_turn)
def test_event_stays_set_while_another_turn_still_cancelled(self):
"""Ending one cancelled turn must not release a still-cancelled sibling."""
controller = InterruptController()
t1 = controller._begin_turn()
t2 = controller._begin_turn()
controller.request("user_stop") # cancels both active turns
controller._end_turn(t1)
# t2 is still cancelled, so the shared event must remain set.
assert controller.event.is_set()
assert controller._turn_is_cancelled(t2)
controller._end_turn(t2)
assert not controller.event.is_set()
def test_event_is_optional_capability_protocol(self):
"""The base protocol stays event-free; the cancellable one requires it."""
from praisonaiagents.agent.interrupt import (
CancellableInterruptControllerProtocol,
)
controller = InterruptController()
assert isinstance(controller, CancellableInterruptControllerProtocol)
class MinimalController:
def request(self, reason="user"):
pass
def clear(self):
pass
def is_set(self):
return False
@property
def reason(self):
return None
def check(self):
pass
# A controller without ``event`` is NOT cancellable-capable but must not
# raise — the runtime probes via getattr(..., 'event', None).
assert not isinstance(MinimalController(), CancellableInterruptControllerProtocol)
class TestLLMLoopCancellation:
"""The litellm tool loop (llm/llm.py) must honour the InterruptController.
@@ -0,0 +1,191 @@
"""The anti-thrashing guard must be able to go stale.
``_low_savings_streak`` stops a compactor that cannot shrink a conversation
from burning a summarisation call every turn. The streak is only cleared
*after* a compaction actually runs -- but the guard returns before reaching
that code, so once it latched it could never be released. A long-running
agent that tripped it stopped compacting for the rest of the session and its
context grew without bound, which is the precise opposite of what auto
compaction is for.
These tests pin both halves of the contract: the guard still suppresses
repeat passes on an unchanged conversation, and it releases once the
conversation has grown enough to be worth re-testing.
"""
import pytest
from praisonaiagents.compaction import CompactionConfig, ContextCompactor
def _config(**overrides):
"""A tight budget so a handful of turns is enough to trip the guard."""
base = dict(
max_tokens=200,
target_tokens=150,
preserve_recent=2,
min_savings_pct=10.0,
max_consecutive_low_savings=2,
)
base.update(overrides)
return CompactionConfig(**base)
def _conversation(turns):
"""A highly compactible conversation: repetitive, summarisable turns."""
return [{"role": "system", "content": "sys"}] + [
{"role": "user", "content": f"Please refactor module_{i}.py and keep the public API stable."}
for i in range(turns)
]
def _latched(config, at_tokens):
"""A compactor in the state a long session reaches after two low-yield passes."""
compactor = ContextCompactor(config=config, strategy="summarize")
compactor._low_savings_streak = config.max_consecutive_low_savings
compactor._low_savings_tokens = at_tokens
return compactor
class TestGuardHolds:
"""Anti-thrashing behaviour must be preserved."""
def test_unchanged_conversation_is_still_skipped(self):
"""Re-running on identical input is the thrashing the guard exists to stop."""
config = _config()
messages = _conversation(200)
probe = ContextCompactor(config=config, strategy="summarize")
compactor = _latched(config, probe.count_total_tokens(messages))
_, result = compactor.compact(messages)
assert result.was_skipped_due_to_low_savings
assert result.messages_removed == 0
def test_marginal_growth_is_still_skipped(self):
"""Growth below the retry threshold is not new evidence.
A couple of extra turns does not justify re-running a summarisation
that just failed to pay for itself; without this the release would
fire on essentially every turn.
"""
config = _config()
probe = ContextCompactor(config=config, strategy="summarize")
compactor = _latched(config, probe.count_total_tokens(_conversation(200)))
_, result = compactor.compact(_conversation(205))
assert result.was_skipped_due_to_low_savings
class TestGuardReleases:
"""A materially larger conversation is new evidence."""
def test_growth_releases_the_guard(self):
"""Past the threshold the verdict is retried, and the retry pays off."""
config = _config()
probe = ContextCompactor(config=config, strategy="summarize")
compactor = _latched(config, probe.count_total_tokens(_conversation(200)))
_, result = compactor.compact(_conversation(300))
assert not result.was_skipped_due_to_low_savings
assert result.messages_removed > 0
# And it must actually shrink -- a released guard that yields nothing
# would mean the release is cosmetic.
assert result.compacted_tokens < result.original_tokens
def test_release_matches_a_fresh_compactor(self):
"""The clearest statement of the bug.
A latched compactor used to hand back untouched the very input an
otherwise-identical fresh one shrinks by ~96%. Once released the two
must be indistinguishable.
"""
config = _config()
messages = _conversation(300)
probe = ContextCompactor(config=config, strategy="summarize")
latched = _latched(config, probe.count_total_tokens(_conversation(200)))
fresh = ContextCompactor(config=config, strategy="summarize")
_, latched_result = latched.compact(messages)
_, fresh_result = fresh.compact(messages)
assert latched_result.compacted_tokens == fresh_result.compacted_tokens
def test_streak_resets_so_the_full_allowance_returns(self):
"""Releasing must clear the streak, not merely skip the check once.
A release that left the streak at the cap would re-latch on the next
call, turning the fix into a one-shot reprieve.
"""
config = _config()
probe = ContextCompactor(config=config, strategy="summarize")
compactor = _latched(config, probe.count_total_tokens(_conversation(200)))
compactor.compact(_conversation(300))
assert compactor._low_savings_streak < config.max_consecutive_low_savings
def test_needs_compaction_agrees_with_compact(self):
"""Both gates must release together.
``needs_compaction()`` is the public gate callers consult. If it kept
reporting False the release would never be reached in the agent's real
code path, and fixing ``compact()`` alone would be dead code.
"""
config = _config()
probe = ContextCompactor(config=config, strategy="summarize")
compactor = _latched(config, probe.count_total_tokens(_conversation(200)))
assert compactor.needs_compaction(_conversation(300)) is True
def test_needs_compaction_still_declines_an_unchanged_conversation(self):
"""The other half of that agreement: both gates must also hold together."""
config = _config()
messages = _conversation(200)
probe = ContextCompactor(config=config, strategy="summarize")
compactor = _latched(config, probe.count_total_tokens(messages))
assert compactor.needs_compaction(messages) is False
class TestLongSession:
"""End-to-end: the failure this guard bug actually caused."""
def test_compaction_keeps_engaging_across_a_long_session(self):
"""No white-box setup: drive a real session and watch compaction die.
This is the failure a user actually hits, reproduced only through the
public API, so it stands even if the guard's internals are reworked.
"""
config = CompactionConfig(max_tokens=300, target_tokens=200, preserve_recent=2)
compactor = ContextCompactor(config=config, strategy="summarize")
messages = [{"role": "system", "content": "You are a coding agent."}]
events = 0
for turn in range(1, 41):
messages.extend([
{"role": "user", "content": f"Step {turn}: refactor module_{turn}.py, keep the API stable."},
{"role": "assistant", "content": f"Done: module_{turn}.py refactored, helper_{turn}() extracted."},
])
compacted, result = compactor.compact(messages)
messages[:] = compacted
if result.messages_removed:
events += 1
# Before the fix this latched at 4 events and then never fired again,
# letting the context grow unbounded for the rest of the session.
assert events > 4, f"compaction stopped engaging after {events} events"
@pytest.mark.parametrize("strategy", ["summarize", "truncate", "sliding"])
def test_guard_release_is_strategy_independent(strategy):
"""The guard lives above strategy selection, so it must not favour one."""
config = _config()
probe = ContextCompactor(config=config, strategy=strategy)
compactor = _latched(config, probe.count_total_tokens(_conversation(200)))
_, result = compactor.compact(_conversation(300))
assert not result.was_skipped_due_to_low_savings
@@ -158,9 +158,69 @@ class TestExpressionConditionClass:
def test_expression_condition_with_previous_output(self):
"""Test ExpressionCondition with previous_output in context."""
from praisonaiagents.conditions.evaluator import ExpressionCondition
cond = ExpressionCondition("success in {{previous_output}}")
# previous_output is passed via context
assert cond.evaluate({"previous_output": "Operation success"}) is True
assert cond.evaluate({"previous_output": "Operation failed"}) is False
class TestEvaluateConditionFailSafeFallback:
"""A comparison operator that never resolves must fail safe to False,
per evaluate_condition's own documented contract, instead of falling
through to the final truthy-string check."""
def test_relational_with_undefined_right_operand(self):
from praisonaiagents.conditions.evaluator import evaluate_condition
assert evaluate_condition("{{score}} > {{threshold}}", {"score": 90}) is False
def test_equality_with_undefined_right_operand(self):
from praisonaiagents.conditions.evaluator import evaluate_condition
assert evaluate_condition("{{status}} == {{expected}}", {"status": "approved"}) is False
def test_relational_with_non_numeric_operands(self):
from praisonaiagents.conditions.evaluator import evaluate_condition
assert evaluate_condition("{{a}} > {{b}}", {"a": "apple", "b": "banana"}) is False
def test_equality_with_undefined_left_operand(self):
from praisonaiagents.conditions.evaluator import evaluate_condition
assert evaluate_condition("{{error_flag}} == true", {}) is False
def test_plain_flag_truthy_check_still_works(self):
"""Guard against over-broadly rejecting values with no comparison
operator at all: the existing boolean/flag path is unaffected."""
from praisonaiagents.conditions.evaluator import evaluate_condition
assert evaluate_condition("{{flag}}", {"flag": "yes"}) is True
assert evaluate_condition("{{flag}}", {"flag": ""}) is False
def test_substituted_value_containing_operator_stays_truthy(self):
"""A placeholder with no comparison operator in the template must
stay on the truthy-string path even if its substituted value
happens to contain '>', '<', '==', etc."""
from praisonaiagents.conditions.evaluator import evaluate_condition
assert evaluate_condition("{{flag}}", {"flag": "ready > now"}) is True
def test_substituted_value_parseable_as_numeric_comparison_stays_truthy(self):
"""A plain flag whose value parses as a numeric comparison
(e.g. "5 < 3") must not be evaluated as a comparison: the template
has no operator, so the non-empty value is truthy."""
from praisonaiagents.conditions.evaluator import evaluate_condition
assert evaluate_condition("{{flag}}", {"flag": "5 < 3"}) is True
assert evaluate_condition("{{flag}}", {"flag": "5 > 3"}) is True
def test_substituted_value_parseable_as_equality_stays_truthy(self):
"""A plain flag whose value parses as an equality comparison
(e.g. "a == b") must stay on the truthy-string path because the
template contains no comparison operator."""
from praisonaiagents.conditions.evaluator import evaluate_condition
assert evaluate_condition("{{flag}}", {"flag": "a == b"}) is True
assert evaluate_condition("{{flag}}", {"flag": "a != b"}) is True
@@ -0,0 +1,250 @@
"""Unit tests for eval/evalport.py - EvalPort framework-neutral adapter.
Covers the dependency-free round-trip between PraisonAI eval models and the
EvalPort spec dicts. A real agentic test (gated by RUN_LIVE_TESTS=1) runs a live
Agent and exports its EvalReport as an EvalPort ResultSet.
"""
import os
import pytest
from praisonaiagents.eval import (
EvalCase,
EvalPackage,
EvalReport,
EvalResult,
from_evalport,
report_to_evalport,
to_evalport,
)
class TestEvalPortImports:
"""Adapter functions must be importable from praisonaiagents.eval."""
def test_public_imports(self):
assert callable(to_evalport)
assert callable(from_evalport)
assert callable(report_to_evalport)
class TestSuiteConversion:
"""EvalPackage <-> EvalPort Suite."""
def _pkg(self):
return EvalPackage(
name="support_agent_eval",
description="Support agent suite",
version="2.1.0",
cases=[
EvalCase(
name="refund_policy",
input="What's your refund policy?",
expected="30 days, unused, receipt required",
criteria=["answer_correct"],
metadata={"tier": "gold"},
timeout_seconds=15.0,
)
],
thresholds={"accuracy": 0.9},
seed=7,
)
def test_to_evalport_shape(self):
suite = to_evalport(self._pkg())
assert suite["kind"] == "suite"
assert suite["name"] == "support_agent_eval"
assert suite["version"] == "2.1.0"
assert suite["thresholds"] == {"accuracy": 0.9}
assert suite["seed"] == 7
assert "evalport_version" in suite
case = suite["cases"][0]
assert case["id"] == "refund_policy"
assert case["input"] == "What's your refund policy?"
assert case["expected_output"] == "30 days, unused, receipt required"
assert case["graders"] == ["answer_correct"]
assert case["metadata"]["tier"] == "gold"
assert case["timeout_seconds"] == 15.0
assert "timeout_seconds" not in case["metadata"]
def test_from_evalport_shape(self):
suite = to_evalport(self._pkg())
pkg = from_evalport(suite)
assert isinstance(pkg, EvalPackage)
assert pkg.name == "support_agent_eval"
assert pkg.version == "2.1.0"
assert pkg.thresholds == {"accuracy": 0.9}
assert pkg.seed == 7
case = pkg.cases[0]
assert isinstance(case, EvalCase)
assert case.name == "refund_policy"
assert case.input == "What's your refund policy?"
assert case.expected == "30 days, unused, receipt required"
assert case.criteria == ["answer_correct"]
assert case.metadata["tier"] == "gold"
assert case.timeout_seconds == 15.0
def test_round_trip_preserves_dict(self):
pkg = self._pkg()
round_tripped = from_evalport(to_evalport(pkg))
assert round_tripped.to_dict() == pkg.to_dict()
def test_external_suite_minimal_fields(self):
"""A suite from another tool with only required fields still imports."""
external = {
"name": "hub_suite",
"cases": [{"id": "c1", "input": "hi"}],
}
pkg = from_evalport(external)
assert pkg.name == "hub_suite"
assert len(pkg.cases) == 1
assert pkg.cases[0].name == "c1"
assert pkg.cases[0].input == "hi"
assert pkg.cases[0].expected is None
def test_case_without_expected_or_criteria(self):
pkg = EvalPackage(name="p", cases=[EvalCase(name="c", input="x")])
case = to_evalport(pkg)["cases"][0]
assert "expected_output" not in case
assert "graders" not in case
def test_metadata_timeout_key_does_not_corrupt_native_timeout(self):
"""A user ``metadata['timeout_seconds']`` must not clobber the native one."""
pkg = EvalPackage(
name="p",
cases=[
EvalCase(
name="c",
input="x",
metadata={"timeout_seconds": 999},
timeout_seconds=15.0,
)
],
)
case = to_evalport(pkg)["cases"][0]
assert case["timeout_seconds"] == 15.0
assert case["metadata"]["timeout_seconds"] == 999
round_tripped = from_evalport(to_evalport(pkg)).cases[0]
assert round_tripped.timeout_seconds == 15.0
assert round_tripped.metadata["timeout_seconds"] == 999
def test_external_suite_metadata_timeout_fallback(self):
"""External suites that only put timeout in metadata still import it."""
external = {
"name": "hub",
"cases": [{"id": "c1", "input": "hi", "metadata": {"timeout_seconds": 45}}],
}
case = from_evalport(external).cases[0]
assert case.timeout_seconds == 45
assert case.metadata["timeout_seconds"] == 45
class TestResultSetConversion:
"""EvalReport -> EvalPort ResultSet."""
def _report(self):
return EvalReport(
package_name="support_agent_eval",
total_cases=2,
passed_cases=1,
failed_cases=1,
average_score=0.6,
thresholds_met={"accuracy": False},
results=[
EvalResult(
case_name="refund_policy",
passed=True,
score=0.95,
actual_output="30 days.",
latency_ms=120.0,
criteria_scores={"answer_correct": 0.95},
),
EvalResult(
case_name="shipping",
passed=False,
score=0.25,
error="timeout",
latency_ms=300.0,
),
],
)
def test_result_set_shape(self):
rs = report_to_evalport(self._report())
assert rs["kind"] == "result_set"
assert rs["suite_name"] == "support_agent_eval"
assert rs["summary"]["total"] == 2
assert rs["summary"]["passed"] == 1
assert rs["summary"]["failed"] == 1
assert rs["summary"]["pass_rate"] == 0.5
assert rs["summary"]["average_score"] == 0.6
assert rs["summary"]["thresholds_met"] == {"accuracy": False}
assert len(rs["results"]) == 2
def test_per_case_fields(self):
rs = report_to_evalport(self._report())
passed, failed = rs["results"]
assert passed["case_id"] == "refund_policy"
assert passed["passed"] is True
assert passed["score"] == 0.95
assert passed["latency_ms"] == 120.0
assert passed["actual_output"] == "30 days."
assert passed["grader_scores"] == {"answer_correct": 0.95}
assert "error" not in passed
assert failed["case_id"] == "shipping"
assert failed["passed"] is False
assert failed["error"] == "timeout"
assert "actual_output" not in failed
assert "grader_scores" not in failed
@pytest.mark.skipif(
os.environ.get("RUN_LIVE_TESTS") != "1",
reason="Real agentic test requires RUN_LIVE_TESTS=1 and an API key",
)
class TestEvalPortRealAgent:
"""Real agentic test: run a live Agent, export the report as EvalPort."""
def test_agent_report_exports_to_evalport(self):
from praisonaiagents import Agent
from praisonaiagents.eval import HarnessEvaluator
agent = Agent(
name="math",
instructions="You answer math questions with only the number.",
)
answer = agent.start("What is 2 + 2?")
assert answer is not None
trace = {
"tool_calls": [],
"artifacts": [],
"judge_score": 10.0 if "4" in str(answer) else 0.0,
}
result = HarnessEvaluator(trace=trace, name="math_addition").run()
report = EvalReport(
package_name="math_eval",
total_cases=1,
passed_cases=1 if result.passed else 0,
failed_cases=0 if result.passed else 1,
average_score=result.score,
results=[
EvalResult(
case_name="math_addition",
passed=result.passed,
score=result.score,
actual_output=str(answer),
)
],
)
result_set = report_to_evalport(report)
assert result_set["kind"] == "result_set"
assert result_set["suite_name"] == "math_eval"
assert result_set["results"][0]["case_id"] == "math_addition"
assert result_set["results"][0]["actual_output"] == str(answer)
@@ -721,7 +721,31 @@ class TestCommandHooks:
assert len(results) == 1
assert results[0].exit_code == 2
assert results[0].output.decision == "deny"
def test_parse_output_allow_json_unexpected_exit_denies(self):
"""Fail closed: allow-JSON must not bypass an unexpected exit code."""
runner = HookRunner(HookRegistry(), cwd="/tmp")
result = runner._parse_command_output(
'{"decision": "allow"}', "not found", 127
)
assert result.decision == "deny"
assert "127" in result.reason
def test_parse_output_allow_json_clean_exit_allows(self):
"""Allow-JSON with a clean exit still allows."""
runner = HookRunner(HookRegistry(), cwd="/tmp")
result = runner._parse_command_output('{"decision": "allow"}', "", 0)
assert result.decision == "allow"
def test_parse_output_deny_json_unexpected_exit_keeps_reason(self):
"""Explicit deny-JSON is respected even on an unexpected exit code."""
runner = HookRunner(HookRegistry(), cwd="/tmp")
result = runner._parse_command_output(
'{"decision": "deny", "reason": "policy"}', "", 127
)
assert result.decision == "deny"
assert result.reason == "policy"
@pytest.mark.asyncio
async def test_command_hook_timeout(self):
"""Test command hook timeout."""
@@ -10,6 +10,7 @@ that never raises.
"""
import asyncio
from types import SimpleNamespace
import pytest
@@ -181,3 +182,96 @@ def test_emit_never_breaks_on_bad_stream_callback():
fallback_index=0,
stream_callback=boom,
)
def test_build_fallback_dispatcher_does_not_touch_shared_state(monkeypatch):
"""The async fallback dispatcher is a throwaway bound to the fallback model
and must never be written onto the shared ``self._unified_dispatcher`` or
change ``self.llm`` — that is what keeps a concurrent turn on the original
model with correct attribution.
"""
from praisonaiagents import Agent
from praisonaiagents import llm as _llm
agent = Agent(name="t", instructions="x", llm="gpt-4o-mini",
fallback_models=["gpt-4o"])
# Avoid needing a real OpenAI client/key: stub the openai client + factory.
agent._Agent__openai_client = object() # backing field of the lazy property
built = []
monkeypatch.setattr(
_llm, "create_llm_dispatcher",
lambda **kw: built.append(kw) or SimpleNamespace(model=kw.get("model")),
)
# Prime the shared dispatcher so we can assert it is left untouched.
sentinel = object()
agent._unified_dispatcher = sentinel
disp = agent._build_fallback_dispatcher("gpt-4o")
assert disp is not None
assert disp is not sentinel # a distinct, throwaway dispatcher
assert disp.model == "gpt-4o" # bound to the fallback model
assert agent._unified_dispatcher is sentinel # shared cache untouched
assert agent.llm == "gpt-4o-mini" # shared model untouched
def test_async_fallback_passes_override_and_leaves_shared_state(monkeypatch):
"""The async fallback branch must run the retry against a throwaway
dispatcher passed via ``_dispatcher=`` and must NOT mutate the shared
``self.llm`` / ``self._unified_dispatcher``, so a concurrent turn on the
same Agent never observes the fallback model.
Regression for the shared-Agent async fallback race (PR #4593 review).
Exercises the fallback branch directly with a stubbed classifier so no real
backoff/network is involved.
"""
from praisonaiagents import Agent
from praisonaiagents import llm as _llm
from praisonaiagents.llm import error_classifier as _ec
agent = Agent(name="t", instructions="x", llm="gpt-4o-mini",
fallback_models=["gpt-4o"])
original_dispatcher = object()
agent._unified_dispatcher = original_dispatcher
# Avoid needing a real OpenAI client/key when the fallback dispatcher builds.
agent._Agent__openai_client = object()
monkeypatch.setattr(
_llm, "create_llm_dispatcher",
lambda **kw: SimpleNamespace(model=kw.get("model")),
)
# Force the classifier to request a model fallback with zero backoff.
classification = SimpleNamespace(
should_compress_context=False,
should_rotate_credential=False,
should_fallback_model=True,
is_retryable=False,
backoff_seconds=0,
error_category="rate_limit",
user_message="fallback",
)
monkeypatch.setattr(_ec, "classify_llm_error", lambda *a, **k: classification)
async def _noop_emit(*a, **k):
return None
agent._aemit_model_fallback = _noop_emit # type: ignore[assignment]
used = []
async def fake_execute(*args, _dispatcher=None, **kwargs):
used.append(_dispatcher)
return "ok-from-fallback"
agent._execute_unified_achat_completion = fake_execute # type: ignore[assignment]
result = asyncio.run(agent._handle_async_llm_error(
RuntimeError("rate limit exceeded"),
[{"role": "user", "content": "hi"}], None, None, True,
False, None, None, None, None, None, True, 0, 0,
))
assert result == "ok-from-fallback"
assert used and used[0] is not None # retry ran on throwaway dispatcher
assert used[0] is not original_dispatcher
assert agent.llm == "gpt-4o-mini" # shared model untouched
assert agent._unified_dispatcher is original_dispatcher # shared cache untouched
@@ -0,0 +1,87 @@
"""
Tests for OpenAI-compatible routing of a bare model name + custom base_url.
A Chat Completions host (e.g. https://api.pzero.studio/v1) exposes catalog
model ids like ``deepseek-v4-flash`` with no ``provider/`` prefix. Without a
prefix LiteLLM cannot infer a provider and raises "LLM Provider NOT provided".
The LLM class should route such a bare model through the OpenAI-compatible
client by prefixing ``openai/`` at request-build time when a base_url is set.
"""
import pytest
from praisonaiagents.llm.llm import LLM
def test_bare_model_with_base_url_routes_openai():
llm = LLM(
model="deepseek-v4-flash",
base_url="https://api.pzero.studio/v1",
api_key="test-key",
)
params = llm._build_completion_params()
assert params["model"] == "openai/deepseek-v4-flash"
assert params["base_url"] == "https://api.pzero.studio/v1"
assert params["api_key"] == "test-key"
def test_prefixed_model_with_base_url_unchanged():
llm = LLM(
model="openai/gpt-4o",
base_url="https://custom.api.com/v1",
)
params = llm._build_completion_params()
assert params["model"] == "openai/gpt-4o"
def test_bare_model_without_base_url_unchanged():
llm = LLM(model="gpt-4o")
params = llm._build_completion_params()
assert params["model"] == "gpt-4o"
def test_anthropic_model_with_base_url_not_reprefixed():
llm = LLM(
model="claude-3-5-sonnet",
base_url="https://proxy.example.com/v1",
)
params = llm._build_completion_params()
assert params["model"] == "claude-3-5-sonnet"
def test_ollama_base_url_not_reprefixed():
llm = LLM(
model="llama3",
base_url="http://localhost:11434",
)
params = llm._build_completion_params()
assert params["model"] == "llama3"
def test_bare_model_with_subscription_base_url_routes_openai():
"""A subscription auth provider (e.g. qwen-cli/codex) injects an
OpenAI-compatible base_url at request time. A bare model must still be
routed through ``openai/`` even though the instance ``base_url`` is empty.
"""
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials
llm = LLM(model="qwen3-coder-plus")
llm._auth_provider_id = "qwen-cli"
llm._cached_subscription_creds = SubscriptionCredentials(
api_key="test-token",
base_url="https://portal.qwen.ai/v1",
)
params = llm._build_completion_params()
assert params["model"] == "openai/qwen3-coder-plus"
assert params["base_url"] == "https://portal.qwen.ai/v1"
def test_bare_model_no_subscription_base_url_unchanged():
"""Subscription creds without a base_url must not trigger prefixing."""
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials
llm = LLM(model="gpt-4o")
llm._auth_provider_id = "some-provider"
llm._cached_subscription_creds = SubscriptionCredentials(api_key="test-token")
params = llm._build_completion_params()
assert params["model"] == "gpt-4o"
@@ -171,7 +171,7 @@ class TestProcessConversation:
manager = LearnManager()
result = manager.process_conversation([])
assert result == {"persona": [], "insights": [], "patterns": [], "stored": {}}
assert result == {"persona": [], "insights": [], "patterns": [], "improvements": [], "stored": {}}
def test_process_conversation_signature(self):
"""process_conversation should have correct signature."""
@@ -384,3 +384,55 @@ Works with internationalization!
assert "José María" in props.metadata["author"] # accented characters
assert "naïve" in props.metadata["notes"] # accented character
assert "émphasis" in props.metadata["notes"] # accented character
class TestReadPropertiesAutomation:
"""Tests for parsing the optional automation frontmatter block."""
def test_read_properties_parses_automation(self):
from praisonaiagents.skills.parser import read_properties
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / "morning-brief"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("""---
name: morning-brief
description: Summarise my calendar and unread priorities for the day.
metadata:
praisonai:
automation:
schedule: "cron:0 8 * * mon-fri"
deliver: "origin"
prompt: "Give me today's brief."
---
# Morning Brief
""")
props = read_properties(skill_dir)
assert props.automation is not None
assert props.automation.schedule == "cron:0 8 * * mon-fri"
assert props.automation.deliver == "origin"
assert props.automation.prompt == "Give me today's brief."
def test_read_properties_no_automation_defaults_none(self):
from praisonaiagents.skills.parser import read_properties
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / "plain-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("""---
name: plain-skill
description: A skill with no automation block.
metadata:
author: test-org
---
# Plain Skill
""")
props = read_properties(skill_dir)
assert props.automation is None
assert props.metadata["author"] == "test-org"
@@ -24,6 +24,25 @@ from unittest.mock import patch, AsyncMock
class TestCompletionDetection:
"""Tests that completion detection uses word boundaries, not substrings."""
def test_completion_patterns_are_compiled_once(self, monkeypatch):
"""Completion checks should reuse patterns compiled at module import."""
import re
from praisonaiagents.agent.agent import Agent
compile_calls = 0
original_compile = re.compile
def counting_compile(*args, **kwargs):
nonlocal compile_calls
compile_calls += 1
return original_compile(*args, **kwargs)
monkeypatch.setattr(re, "compile", counting_compile)
assert Agent._is_completion_signal("Task completed.") is True
assert Agent._is_completion_signal("Still working.") is False
assert compile_calls == 0
def _make_agent(self):
"""Create a minimal autonomy-enabled agent."""
from praisonaiagents import Agent
@@ -0,0 +1,189 @@
"""Unit tests for the durable process-lifecycle primitives (Issue #4603).
Covers the pure, zero-I/O core seam that lets a hosted gateway detect an
unclean/OOM death of its *previous* process at boot, classify memory/disk
pressure for the health surface, and run a restart-loop breaker whose events
survive the restart it guards:
* :class:`LifecycleRecord` (JSON round-trip),
* :func:`classify_unclean_exit`,
* :func:`classify_resource_pressure`, and
* :class:`PersistentRestartLoopGuard` over a :class:`RestartStoreProtocol`.
"""
from typing import List
import pytest
from praisonaiagents.gateway import (
LifecycleRecord,
PersistentRestartLoopGuard,
RestartStoreProtocol,
classify_resource_pressure,
classify_unclean_exit,
)
# ── LifecycleRecord round-trip ──
def test_record_round_trips_through_dict():
rec = LifecycleRecord(
phase="running",
exit_kind="unclean",
suspected_oom=True,
last_mem_fraction=0.92,
restart_events=[1.0, 2.0, 3.0],
)
back = LifecycleRecord.from_dict(rec.to_dict())
assert back == rec
def test_from_dict_tolerates_none_and_junk():
assert LifecycleRecord.from_dict(None) == LifecycleRecord()
junk = LifecycleRecord.from_dict(
{"phase": "bogus", "exit_kind": "nope", "restart_events": "x", "last_mem_fraction": "y"}
)
assert junk.phase == "running"
assert junk.exit_kind == "unknown"
assert junk.restart_events == []
assert junk.last_mem_fraction is None
def test_from_dict_filters_non_numeric_events():
rec = LifecycleRecord.from_dict({"restart_events": [1.0, "bad", 3.0, None]})
assert rec.restart_events == [1.0, 3.0]
# ── classify_unclean_exit ──
def test_no_prior_record_is_unknown():
rec = classify_unclean_exit(None)
assert rec.exit_kind == "unknown"
assert rec.phase == "running"
assert rec.suspected_oom is False
def test_prior_exited_is_clean():
prev = LifecycleRecord(phase="exited", exit_kind="clean")
rec = classify_unclean_exit(prev)
assert rec.exit_kind == "clean"
assert rec.phase == "running"
def test_prior_running_is_unclean():
prev = LifecycleRecord(phase="running", last_mem_fraction=0.5)
rec = classify_unclean_exit(prev)
assert rec.exit_kind == "unclean"
assert rec.suspected_oom is False
def test_prior_running_high_mem_is_suspected_oom():
prev = LifecycleRecord(phase="running", last_mem_fraction=0.95)
rec = classify_unclean_exit(prev, oom_fraction=0.9)
assert rec.exit_kind == "unclean"
assert rec.suspected_oom is True
def test_prior_draining_is_unclean():
prev = LifecycleRecord(phase="draining")
rec = classify_unclean_exit(prev)
assert rec.exit_kind == "unclean"
def test_restart_events_carry_forward():
prev = LifecycleRecord(phase="running", restart_events=[1.0, 2.0])
rec = classify_unclean_exit(prev)
assert rec.restart_events == [1.0, 2.0]
# ── classify_resource_pressure ──
def test_pressure_missing_signals_are_unknown_not_ok():
p = classify_resource_pressure()
assert p == {"memory": "unknown", "disk": "unknown"}
def test_pressure_memory_ladder():
assert classify_resource_pressure(mem_fraction=0.1)["memory"] == "ok"
assert classify_resource_pressure(mem_fraction=0.8)["memory"] == "elevated"
assert classify_resource_pressure(mem_fraction=0.95)["memory"] == "critical"
def test_pressure_disk_ladder():
assert classify_resource_pressure(disk_free_mb=100, disk_min_free_mb=512)["disk"] == "critical"
assert classify_resource_pressure(disk_free_mb=700, disk_min_free_mb=512)["disk"] == "elevated"
assert classify_resource_pressure(disk_free_mb=5000, disk_min_free_mb=512)["disk"] == "ok"
# ── PersistentRestartLoopGuard ──
class _FakeStore:
"""In-memory RestartStoreProtocol double."""
def __init__(self, initial: List[float] = None):
self.data: List[float] = list(initial or [])
self.raise_on_save = False
def load_events(self) -> List[float]:
return list(self.data)
def save_events(self, events: List[float]) -> None:
if self.raise_on_save:
raise IOError("boom")
self.data = list(events)
def test_store_double_satisfies_protocol():
assert isinstance(_FakeStore(), RestartStoreProtocol)
def test_guard_persists_events_on_record():
store = _FakeStore()
guard = PersistentRestartLoopGuard(store, max_restarts=3, window_seconds=3600)
assert guard.record(now=1.0) is False
assert guard.record(now=2.0) is False
assert guard.record(now=3.0) is True
assert store.data == [1.0, 2.0, 3.0]
def test_guard_reloads_persisted_events_across_boots():
store = _FakeStore()
g1 = PersistentRestartLoopGuard(store, max_restarts=3, window_seconds=1e12)
g1.record(now=1.0)
g1.record(now=2.0)
# A fresh guard (new process) reloads history and is one restart from tripping.
g2 = PersistentRestartLoopGuard(store, max_restarts=3, window_seconds=1e12)
assert g2.tripped(now=3.0) is False
assert g2.record(now=3.0) is True
def test_guard_reset_clears_store():
store = _FakeStore([1.0, 2.0])
guard = PersistentRestartLoopGuard(store, max_restarts=2, window_seconds=1e12)
guard.reset()
assert store.data == []
def test_guard_survives_store_failure():
store = _FakeStore()
store.raise_on_save = True
guard = PersistentRestartLoopGuard(store, max_restarts=2, window_seconds=1e12)
# A failing store must not crash the breaker: it degrades to in-memory.
assert guard.record(now=1.0) is False
assert guard.record(now=2.0) is True
def test_guard_load_failure_falls_back_to_empty():
class _Broken:
def load_events(self):
raise IOError("nope")
def save_events(self, events):
pass
guard = PersistentRestartLoopGuard(_Broken(), max_restarts=2, window_seconds=1e12)
assert guard.record(now=1.0) is False
@@ -380,6 +380,35 @@ class TestMaxRpmAutoWiresRateLimiter:
execution=ExecutionConfig(max_rpm=-1, rate_limiter=custom),
)
def test_auto_wired_limiter_actually_throttles(self):
"""The auto-built limiter must space requests, not just exist.
Uses an injectable clock/sleep so no real time passes: with
max_rpm=1 and burst=1, the second acquire() must wait ~60s.
"""
from praisonaiagents import Agent
from praisonaiagents.config.feature_configs import ExecutionConfig
agent = Agent(
name="test",
instructions="test",
execution=ExecutionConfig(max_rpm=1),
)
limiter = agent._rate_limiter
clock = [0.0]
slept = []
def fake_sleep(seconds):
slept.append(seconds)
clock[0] += seconds
limiter._get_time = lambda: clock[0]
limiter._sleep = fake_sleep
limiter.reset()
limiter.acquire() # consumes the single burst token, no wait
assert slept == []
limiter.acquire() # must wait ~60s for the next token at 1 rpm
assert slept, "second acquire() should have blocked under max_rpm=1"
assert abs(sum(slept) - 60.0) < 1e-6
class TestLegacyKwargsCompatibility:
"""Signature-reduction backward-compat: keyword-only guard, internal
@@ -0,0 +1,69 @@
"""Cron schedules must actually fire, and must be refused when they cannot.
``croniter`` was declared in none of the nine pyproject.toml files, so on a
default install every cron-kind schedule -- and every natural-language
recurring one, since "every day at 9am" is converted to cron -- was accepted,
persisted, and returned False from is_due() forever. No error at creation; a
warning in a log nobody reads; a job that never runs.
Three guards, one per layer of the failure:
* the dependency is declared, so a fresh install has the engine;
* a cron job genuinely becomes due, proving the runtime path end to end;
* creation refuses a cron schedule when the engine is absent, so the
failure is loud at the one moment the user can act on it.
"""
import pathlib
import sys
import pytest
from praisonaiagents.scheduler.due import is_due
from praisonaiagents.scheduler.models import ScheduleJob
from praisonaiagents.scheduler.parser import parse_schedule
_PYPROJECT = pathlib.Path(__file__).resolve().parents[2] / "pyproject.toml"
EIGHT_DAYS = 8 * 86400 # covers daily AND weekly ("every monday") schedules
def _job(expr: str) -> ScheduleJob:
return ScheduleJob(name="t", schedule=parse_schedule(expr), message="hi")
def test_cron_engine_is_a_declared_dependency():
"""Packaging, not code, was the defect; pin the packaging."""
text = _PYPROJECT.read_text()
deps = text[text.index("dependencies = ["):]
deps = deps[: deps.index("]")]
assert "croniter" in deps, "croniter missing from core dependencies"
@pytest.mark.parametrize("expr", [
"every day at 9am",
"weekdays at 9am",
"every monday 9am",
"cron:0 9 * * *",
])
def test_recurring_schedule_actually_becomes_due(expr):
"""The effect: eight days after creation, daily and weekly jobs are due."""
job = _job(expr)
assert job.schedule.kind == "cron", "these must route through cron"
assert is_due(job, job.created_at + EIGHT_DAYS), (
f"{expr!r} was accepted but never becomes due"
)
def test_interval_and_one_shot_do_not_need_the_engine(monkeypatch):
"""Do not over-restrict: 'hourly' and 'in 20 minutes' never used cron."""
monkeypatch.setitem(sys.modules, "croniter", None)
for expr in ("hourly", "*/30m", "in 20 minutes"):
job = _job(expr)
assert is_due(job, job.created_at + EIGHT_DAYS)
def test_cron_schedule_is_refused_when_the_engine_is_missing(monkeypatch):
"""Loud at creation. Setting the module to None makes import raise."""
monkeypatch.setitem(sys.modules, "croniter", None)
with pytest.raises(ValueError, match="cron engine"):
parse_schedule("every day at 9am")
with pytest.raises(ValueError, match="cron engine"):
parse_schedule("cron:0 9 * * *")
@@ -184,3 +184,101 @@ class TestScheduleToolImportPaths:
def test_import_scheduler_runner(self):
from praisonaiagents.scheduler import ScheduleRunner
assert ScheduleRunner is not None
class TestNaturalLanguageSchedule:
"""Natural-language clock-time / day-of-week schedule expressions."""
def test_bare_clock_time_is_one_shot(self):
from praisonaiagents.scheduler import parse_schedule
s = parse_schedule("at 9am")
assert s.kind == "at"
assert s.at is not None and "09:00" in s.at
def test_clock_time_with_minutes_pm(self):
from praisonaiagents.scheduler import parse_schedule
s = parse_schedule("at 9:30pm")
assert s.kind == "at"
assert "21:30" in s.at
def test_every_day_at_clock_time(self):
from praisonaiagents.scheduler import parse_schedule
s = parse_schedule("every day at 9am")
assert s.kind == "cron"
assert s.cron_expr == "0 9 * * *"
def test_daily_at_clock_time(self):
from praisonaiagents.scheduler import parse_schedule
s = parse_schedule("daily at 9am")
assert s.kind == "cron"
assert s.cron_expr == "0 9 * * *"
def test_weekdays_at_clock_time(self):
from praisonaiagents.scheduler import parse_schedule
s = parse_schedule("weekdays at 9am")
assert s.kind == "cron"
assert s.cron_expr == "0 9 * * 1-5"
def test_weekends_at_clock_time(self):
from praisonaiagents.scheduler import parse_schedule
s = parse_schedule("weekends at 10:30am")
assert s.kind == "cron"
assert s.cron_expr == "30 10 * * 0,6"
def test_every_single_weekday(self):
from praisonaiagents.scheduler import parse_schedule
s = parse_schedule("every monday 9am")
assert s.kind == "cron"
assert s.cron_expr == "0 9 * * 1"
def test_multiple_days_comma_list(self):
from praisonaiagents.scheduler import parse_schedule
s = parse_schedule("every mon,wed,fri at 9am")
assert s.kind == "cron"
assert s.cron_expr == "0 9 * * 1,3,5"
def test_24h_clock(self):
from praisonaiagents.scheduler import parse_schedule
s = parse_schedule("every day at 17:00")
assert s.kind == "cron"
assert s.cron_expr == "0 17 * * *"
def test_legacy_forms_unchanged(self):
from praisonaiagents.scheduler import parse_schedule
assert parse_schedule("hourly").kind == "every"
assert parse_schedule("*/30m").kind == "every"
assert parse_schedule("cron:0 7 * * *").kind == "cron"
assert parse_schedule("in 20 minutes").kind == "at"
def test_invalid_still_raises(self):
import pytest
from praisonaiagents.scheduler import parse_schedule
for bad in ("gibberish", "at bananas", "every florp"):
with pytest.raises(ValueError):
parse_schedule(bad)
def test_one_shot_clock_time_honors_timezone(self):
from datetime import datetime
from zoneinfo import ZoneInfo
from praisonaiagents.scheduler import parse_schedule
s = parse_schedule("at 9am", tz="America/New_York")
assert s.kind == "at"
target = datetime.fromisoformat(s.at)
# The clock time denotes 09:00 local (New York), not 09:00 UTC.
assert target.tzinfo is not None
local = target.astimezone(ZoneInfo("America/New_York"))
assert (local.hour, local.minute) == (9, 0)
def test_one_shot_clock_time_is_due_at_local_hour(self):
from datetime import datetime
from praisonaiagents.scheduler import parse_schedule
from praisonaiagents.scheduler.due import is_due
from praisonaiagents.scheduler.models import ScheduleJob
s = parse_schedule("at 9am", tz="America/New_York")
target = datetime.fromisoformat(s.at)
job = ScheduleJob(name="j1", schedule=s, message="p")
# Just before the target: not due. At/after target: due.
assert is_due(job, target.timestamp() - 1) is False
assert is_due(job, target.timestamp()) is True
@@ -0,0 +1,137 @@
"""The streaming entry point must run context compaction (Issue #4714).
Agent auto-enables a ContextManager whenever tools are present (auto_compact,
compact_threshold 0.8). The non-streaming ``chat()`` path runs
``_apply_context_management`` between building the messages and issuing the
request; ``start(stream=True)`` built the same messages and sent them
unmodified, so a long session streamed the entire history to the model every
turn while the manager sat idle. The desktop's default agent has tools, so it
hit this on every streamed turn.
These tests stub the transport and assert the EFFECT: the message list that
actually reaches the transport is bounded when a ContextManager is configured,
and untouched when it is not.
"""
import pytest
from praisonaiagents import Agent
SEEDED = 400 # ~160k estimated tokens; well past 80% of gpt-4o-mini's 128k window
FILLER = "lorem ipsum dolor sit amet " * 60
def _add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
class _RecordingOpenAI:
"""A proxy over the real client wrapper: everything (build_messages, tool
formatting, ...) is delegated; only the transport call is intercepted and
recorded. ``iter([])`` is an empty stream -- no answer, no tool calls."""
def __init__(self, real):
self._real = real
self.calls = []
outer = self
class _Completions:
def create(self, **kw):
outer.calls.append(kw)
return iter([])
class _Chat:
completions = _Completions()
class _Sync:
chat = _Chat()
self.sync_client = _Sync()
def __getattr__(self, name):
return getattr(self._real, name)
def _seed_history(agent, n=SEEDED):
for i in range(n):
agent.chat_history.append({
"role": "user" if i % 2 == 0 else "assistant",
"content": f"msg {i} {FILLER}",
})
def _stub_openai(agent):
from praisonaiagents.llm import get_openai_client
stub = _RecordingOpenAI(get_openai_client(api_key="sk-test"))
agent._Agent__openai_client = stub # the lazily-built client, pre-seeded
return stub
@pytest.fixture(autouse=True)
def _api_key(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
def test_openai_stream_compacts_long_history():
agent = Agent(name="t", instructions="x", llm="gpt-4o-mini", tools=[_add])
assert agent.context_manager is not None, "tools present must auto-enable a ContextManager"
_seed_history(agent)
stub = _stub_openai(agent)
"".join(agent.start("x", stream=True))
assert stub.calls, "the stub transport was never called"
sent = stub.calls[0]["messages"]
# Far fewer than seeded: compaction at threshold 0.8 keeps the window
# under budget, not merely one message under the seed count.
assert len(sent) <= SEEDED * 0.7, (
f"streamed request carried {len(sent)} messages for {SEEDED} seeded; "
"context compaction did not run on the streaming path"
)
# Compaction must not lose the request itself.
assert sent[0]["role"] == "system"
assert sent[-1] == {"role": "user", "content": "x"}
def test_openai_stream_without_context_manager_sends_full_history():
"""Zero-overhead contract: context=False leaves the streamed request alone.
Also pins that the bounded count above is compaction and not some other
trimming in the streaming path."""
agent = Agent(name="t", instructions="x", llm="gpt-4o-mini", tools=[_add], context=False)
assert agent.context_manager is None
_seed_history(agent)
stub = _stub_openai(agent)
"".join(agent.start("x", stream=True))
sent = stub.calls[0]["messages"]
assert len(sent) == SEEDED + 2 # system + seeded history + new user turn
def test_custom_llm_stream_compacts_long_history():
"""The LiteLLM branch passes chat_history rather than a built messages list;
compaction must apply to the history that is actually sent."""
agent = Agent(name="t", instructions="x", llm="openai/gpt-4o-mini", tools=[_add])
assert agent.context_manager is not None
_seed_history(agent)
seen = {}
def fake_stream(**kw):
seen.update(kw)
yield "ok"
agent.llm_instance.get_response_stream = fake_stream
"".join(agent.start("x", stream=True))
assert "chat_history" in seen, "the fake transport was never called"
history = seen["chat_history"]
assert len(history) <= SEEDED * 0.7, (
f"streamed custom-LLM request carried {len(history)} history messages for "
f"{SEEDED} seeded; context compaction did not run on the streaming path"
)
# The turn just appended must survive compaction.
assert history[-1] == {"role": "user", "content": "x"}
@@ -341,6 +341,207 @@ class TestApplyPatch:
assert _read(b) == "original b\n"
class TestMoveFile:
def test_move_renames_preserving_content(self, tools, tmp_path):
src = tmp_path / "old.py"
dst = tmp_path / "new.py"
_write(src, "print('hi')\n")
patch = f"*** Move File: {src} -> {dst}\n"
result = tools.apply_patch(patch)
assert "Success" in result
assert "Renamed" in result
assert not os.path.exists(src)
assert _read(dst) == "print('hi')\n"
def test_move_missing_source_fails(self, tools, tmp_path):
src = tmp_path / "nope.py"
dst = tmp_path / "new.py"
patch = f"*** Move File: {src} -> {dst}\n"
result = tools.apply_patch(patch)
assert "not found" in result
assert not os.path.exists(dst)
def test_move_existing_destination_fails(self, tools, tmp_path):
src = tmp_path / "old.py"
dst = tmp_path / "new.py"
_write(src, "a\n")
_write(dst, "b\n")
patch = f"*** Move File: {src} -> {dst}\n"
result = tools.apply_patch(patch)
assert "destination already exists" in result
# Both endpoints untouched.
assert _read(src) == "a\n"
assert _read(dst) == "b\n"
def test_move_with_hunks_renames_and_edits(self, tools, tmp_path):
src = tmp_path / "foo.py"
dst = tmp_path / "bar.py"
_write(src, "def foo():\n return 1\n")
patch = (
f"*** Move File: {src} -> {dst}\n"
"@@\n"
" return 1\n"
"===\n"
" return 2\n"
)
result = tools.apply_patch(patch)
assert "Success" in result
assert not os.path.exists(src)
assert "return 2" in _read(dst)
def test_move_preserves_crlf_and_bom(self, tools, tmp_path):
src = tmp_path / "crlf.py"
dst = tmp_path / "crlf_new.py"
with open(src, "wb") as f:
f.write(b"\xef\xbb\xbfdef foo():\r\n return 1\r\n")
patch = (
f"*** Move File: {src} -> {dst}\n"
"@@\n"
" return 1\n"
"===\n"
" return 2\n"
)
result = tools.apply_patch(patch)
assert "Success" in result
assert not os.path.exists(src)
with open(dst, "rb") as f:
data = f.read()
assert data.startswith(b"\xef\xbb\xbf") # BOM preserved
assert b"\r\n" in data # CRLF preserved
assert b"return 2" in data
def test_move_atomic_rollback_on_later_failure(self, tools, tmp_path):
# A move committed before a failing update must be rolled back.
src = tmp_path / "old.py"
dst = tmp_path / "new.py"
missing = tmp_path / "missing.py" # update fails -> whole patch aborts
_write(src, "content\n")
patch = (
f"*** Move File: {src} -> {dst}\n"
f"*** Update File: {missing}\n"
"@@\n"
"x\n"
"===\n"
"y\n"
)
result = tools.apply_patch(patch)
assert "Error" in result
# Source restored, destination not left behind.
assert _read(src) == "content\n"
assert not os.path.exists(dst)
def test_move_malformed_header_fails(self, tools, tmp_path):
src = tmp_path / "old.py"
_write(src, "x\n")
patch = f"*** Move File: {src}\n" # no ' -> <new>'
result = tools.apply_patch(patch)
assert "Invalid patch" in result
def test_move_stale_source_aborts(self, tools, tmp_path):
src = tmp_path / "old.py"
dst = tmp_path / "new.py"
_write(src, "v1\n")
tools.read_file(str(src)) # record read hash
_write(src, "v2\n") # external change
patch = f"*** Move File: {src} -> {dst}\n"
result = tools.apply_patch(patch)
assert "changed" in result
assert os.path.exists(src)
assert not os.path.exists(dst)
# force bypasses the guard.
assert "Success" in tools.apply_patch(patch, force=True)
assert not os.path.exists(src)
def test_move_does_not_clobber_sibling_staging_file(self, tools, tmp_path):
# A pre-existing file at the default '.praison_patch_tmp' staging name
# for the destination must not be truncated or deleted by the move.
src = tmp_path / "old.py"
dst = tmp_path / "new.py"
_write(src, "moved\n")
sibling = tmp_path / "new.py.praison_patch_tmp"
_write(sibling, "PRECIOUS\n")
src_sibling = tmp_path / "old.py.praison_patch_tmp"
_write(src_sibling, "ALSO_PRECIOUS\n")
patch = f"*** Move File: {src} -> {dst}\n"
result = tools.apply_patch(patch)
assert "Success" in result
assert _read(dst) == "moved\n"
# The unrelated sibling files survive untouched.
assert _read(sibling) == "PRECIOUS\n"
assert _read(src_sibling) == "ALSO_PRECIOUS\n"
def test_two_moves_to_same_destination_conflict(self, tools, tmp_path):
# Two operations producing the same destination must be rejected up
# front instead of one silently clobbering the other.
a = tmp_path / "a.py"
b = tmp_path / "b.py"
dst = tmp_path / "out.py"
_write(a, "AAA\n")
_write(b, "BBB\n")
patch = (
f"*** Move File: {a} -> {dst}\n"
f"*** Move File: {b} -> {dst}\n"
)
result = tools.apply_patch(patch)
assert "Conflicting operations" in result
# Nothing committed: both sources intact, destination absent.
assert _read(a) == "AAA\n"
assert _read(b) == "BBB\n"
assert not os.path.exists(dst)
def test_move_then_add_same_destination_conflict(self, tools, tmp_path):
src = tmp_path / "src.py"
dst = tmp_path / "dst.py"
_write(src, "S\n")
patch = (
f"*** Move File: {src} -> {dst}\n"
f"*** Add File: {dst}\n"
"added\n"
)
result = tools.apply_patch(patch)
assert "Conflicting operations" in result
assert _read(src) == "S\n"
assert not os.path.exists(dst)
def test_move_into_freed_source_path_allowed(self, tools, tmp_path):
# Moving A->B then B-content isn't relevant; but a move that frees a
# path lets a later add reuse it within the same patch.
a = tmp_path / "a.py"
b = tmp_path / "b.py"
_write(a, "A\n")
patch = (
f"*** Move File: {a} -> {b}\n"
f"*** Add File: {a}\n"
"fresh\n"
)
result = tools.apply_patch(patch)
assert "Success" in result
assert _read(b) == "A\n"
assert _read(a) == "fresh"
def test_move_rollback_rearms_source_staleness_guard(self, tools, tmp_path):
# After a move is rolled back, a later edit of the restored source must
# still detect an external change (staleness guard re-armed).
src = tmp_path / "old.py"
dst = tmp_path / "new.py"
missing = tmp_path / "missing.py"
_write(src, "v1\n")
tools.read_file(str(src)) # record read hash for staleness guard
patch = (
f"*** Move File: {src} -> {dst}\n"
f"*** Update File: {missing}\n"
"@@\nx\n===\ny\n"
)
result = tools.apply_patch(patch)
assert "Error" in result
assert _read(src) == "v1\n"
assert not os.path.exists(dst)
# Simulate an external modification of the restored source.
_write(src, "v2\n")
stale = tools.edit_file(str(src), "v2", "v3")
assert "changed since it was read" in stale
class TestPostEditDiagnostics:
"""Post-edit diagnostics feedback for the built-in editor."""
@@ -0,0 +1,69 @@
"""Tests for cooperative interrupt aborting an in-flight shell subprocess.
Verifies that a running shell command is terminated promptly when the agent's
cancellation Event fires (issue #4596), instead of running until its timeout.
"""
import os
import sys
import threading
import time
import pytest
from praisonaiagents.tools.shell_tools import ShellTools
from praisonaiagents.tools.injected import AgentState, with_injection_context
from praisonaiagents.agent.interrupt import InterruptController
@pytest.fixture(autouse=True)
def _auto_approve(monkeypatch):
# execute_command is approval-gated (critical); auto-approve for the test.
monkeypatch.setenv("PRAISONAI_AUTO_APPROVE", "true")
def _state_with(cancel_event):
return AgentState(
agent_id="test",
run_id="run",
session_id="sess",
cancel_event=cancel_event,
)
def test_interrupt_aborts_running_command_within_grace():
"""A long sleep is interrupted well before its timeout and reports interrupted."""
controller = InterruptController()
tools = ShellTools()
result_holder = {}
def run():
state = _state_with(controller.event)
with with_injection_context(state):
result_holder["result"] = tools.execute_command(
f"{sys.executable} -c \"import time; time.sleep(30)\"",
timeout=30,
)
worker = threading.Thread(target=run)
start = time.time()
worker.start()
time.sleep(0.5) # let the subprocess start
controller.request("user_stop")
worker.join(timeout=10)
elapsed = time.time() - start
assert not worker.is_alive(), "shell command was not aborted"
assert elapsed < 10, f"interrupt took too long: {elapsed:.1f}s"
result = result_holder["result"]
assert result.get("interrupted") is True
assert result.get("success") is False
def test_no_cancel_event_preserves_normal_behaviour():
"""Without a cancel_event a quick command still returns normally."""
tools = ShellTools()
result = tools.execute_command(f"{sys.executable} -c \"print('hi')\"", timeout=10)
assert result.get("success") is True
assert "hi" in result.get("stdout", "")
+20 -6
View File
@@ -850,6 +850,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/72/51/a2eba2f9ea7d40ddd69d533eeacf835b27946f32204705ae3333bb61b118/crawl4ai-0.8.5-py3-none-any.whl", hash = "sha256:058ff53792e238714bad0045303cade859514f4bcaea8887acb0a190c6798654", size = 500303, upload-time = "2026-03-18T03:34:22.117Z" },
]
[[package]]
name = "croniter"
version = "6.2.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/37/57/2e2a65aee2a70483cb28e2b7e15a072d00a523207593b44400d4717bb100/croniter-6.2.4.tar.gz", hash = "sha256:fc124f751b1b04805c2a04b061898b436b45ab2320b045e1e052ea895de65189", size = 166267, upload-time = "2026-07-10T09:52:59.955Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/ba/d678e5bd329646ca51d3c92addbc77804e86d21f4b6b6a027218e6abb010/croniter-6.2.4-py3-none-any.whl", hash = "sha256:8ef3d544107a5c05a150a2d78f8bf5a8eb9c5c4d93405a736b824109574e3f4d", size = 46677, upload-time = "2026-07-10T09:52:58.425Z" },
]
[[package]]
name = "cryptography"
version = "46.0.5"
@@ -1008,7 +1020,7 @@ name = "exceptiongroup"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
wheels = [
@@ -3111,10 +3123,11 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.7.2"
version = "1.7.3"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
{ name = "croniter" },
{ name = "openai" },
{ name = "posthog" },
{ name = "pydantic" },
@@ -3227,6 +3240,7 @@ requires-dist = [
{ name = "chromadb", marker = "extra == 'knowledge'", specifier = ">=1.0.0" },
{ name = "chromadb", marker = "extra == 'memory'", specifier = ">=1.0.0" },
{ name = "crawl4ai", marker = "extra == 'crawl'", specifier = ">=0.4.0" },
{ name = "croniter", specifier = ">=6.0" },
{ name = "dakera", marker = "extra == 'dakera'", specifier = ">=0.12.8" },
{ name = "ddgs", marker = "extra == 'search'", specifier = ">=9.0.0" },
{ name = "fastapi", marker = "extra == 'api'", specifier = ">=0.115.0" },
@@ -4367,7 +4381,7 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
{ name = "numpy" },
{ name = "numpy", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" }
wheels = [
@@ -4430,7 +4444,7 @@ resolution-markers = [
"python_full_version == '3.11.*'",
]
dependencies = [
{ name = "numpy" },
{ name = "numpy", marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
wheels = [
@@ -4694,8 +4708,8 @@ name = "standard-aifc"
version = "3.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "audioop-lts" },
{ name = "standard-chunk" },
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
{ name = "standard-chunk", marker = "python_full_version >= '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" }
wheels = [
@@ -0,0 +1,311 @@
"""Subprocess execution that cannot hang the caller indefinitely.
Several gateway paths shell out while an asyncio event loop is waiting on
them. ``subprocess.run`` without ``timeout=`` blocks forever if the child
waits on stdin -- a git credential prompt, an ``index.lock`` contention, a
confirmation prompt -- and the whole gateway stops serving.
``subprocess.run(timeout=...)`` alone is not enough, because it only kills
the *direct* child:
* On POSIX its timeout path calls ``kill()`` then ``wait()``. The call
returns, but any grandchild the command spawned is orphaned and keeps
running.
* On Windows it calls ``kill()`` then ``communicate()`` with **no timeout**.
A surviving grandchild that inherited the captured stdout/stderr pipe holds
it open, so that ``communicate()`` blocks until the grandchild exits --
reintroducing exactly the indefinite hang this module exists to prevent.
That is not hypothetical for the calls here: ``git`` routinely spawns
credential helpers and transport processes, and a stuck credential helper is
the motivating failure.
:func:`run_guarded` therefore runs the command in its own process group (or
Windows process group), kills the whole group on timeout, and then reaps
with a *bounded* second wait so a stubborn descendant can never block the
caller.
Note on semantics: this is a total-runtime cap, not an inactivity timer. A
command that streams output steadily for longer than the cap is still killed.
Callers with legitimately long work should pass an explicit ``timeout``.
"""
from __future__ import annotations
import logging
import math
import os
import signal
import subprocess
from typing import Mapping, Optional, Sequence
__all__ = ["run_guarded", "DEFAULT_TIMEOUT", "TIMEOUT_RETURNCODE", "diagnose"]
logger = logging.getLogger(__name__)
#: Total seconds a guarded command may run before it is killed.
#: Override per call, or globally via ``PRAISONAI_SUBPROCESS_TIMEOUT``.
DEFAULT_TIMEOUT = 120.0
#: Returncode reported for a killed command. 124 is the conventional exit
#: status used by coreutils ``timeout``.
TIMEOUT_RETURNCODE = 124
#: Seconds allowed for the post-kill reap. Bounded on purpose: if a
#: descendant somehow survives the group kill and still holds the pipe, we
#: abandon the output rather than block the caller.
REAP_TIMEOUT = 5.0
_ENV_TIMEOUT = "PRAISONAI_SUBPROCESS_TIMEOUT"
# Substrings that identify why a child process is sitting idle. Ordered most
# specific first; the first hit wins.
_SIGNATURES: tuple[tuple[tuple[str, ...], str], ...] = (
(
("username for", "password for", "passphrase", "authentication failed"),
"DETECTED: credential prompt. The command is waiting for a login that "
"will never arrive. Configure a credential helper, or set "
"GIT_TERMINAL_PROMPT=0 so it fails fast instead of blocking.",
),
(
("index.lock", "unable to create", "another git process"),
"DETECTED: git lock contention. Another git process holds the "
"repository lock. Retry once it exits, or remove a stale .git/index.lock.",
),
(
("[y/n]", "(yes/no)", "are you sure", "continue?", "overwrite?", "proceed?"),
"DETECTED: confirmation prompt. Re-run non-interactively with the "
"command's own flag (--yes, -y, --force, --no-confirm).",
),
(
("host key", "fingerprint", "known_hosts"),
"DETECTED: SSH host-key prompt. Pre-populate known_hosts, or pass "
"-o StrictHostKeyChecking=accept-new.",
),
)
_GENERIC = (
"No interactive prompt was detected. The command may be genuinely slow, "
"blocked on network I/O, or deadlocked. Raise timeout= if the work is "
"expected to take longer."
)
def diagnose(captured: str) -> str:
"""Return a human- and model-actionable reason a command stalled.
``captured`` is whatever the child emitted before it was killed; it may be
empty, which is itself common for a process blocked on stdin.
"""
haystack = (captured or "").lower()
for needles, message in _SIGNATURES:
if any(needle in haystack for needle in needles):
return message
return _GENERIC
def _default_timeout() -> float:
"""Resolve the global timeout, ignoring an unusable env override.
``inf`` is rejected alongside zero and negatives: it parses cleanly and is
positive, so a bare ``value > 0`` check would accept it and hand back an
unbounded wait -- reinstating the exact hang this module prevents. ``nan``
fails the comparison on its own, but is covered explicitly for clarity.
"""
raw = os.environ.get(_ENV_TIMEOUT)
if not raw:
return DEFAULT_TIMEOUT
try:
value = float(raw)
except (TypeError, ValueError):
return DEFAULT_TIMEOUT
if not math.isfinite(value) or value <= 0:
return DEFAULT_TIMEOUT
return value
def _non_interactive_env(env: Optional[Mapping[str, str]]) -> dict:
"""Environment that makes common tools fail rather than prompt.
Suppressing the prompt at source is better than killing the process after
it hangs: the command returns an error the caller can act on immediately.
"""
resolved = dict(env if env is not None else os.environ)
resolved.setdefault("GIT_TERMINAL_PROMPT", "0")
resolved.setdefault("GCM_INTERACTIVE", "never")
resolved.setdefault("DEBIAN_FRONTEND", "noninteractive")
return resolved
def _kill_process_group(proc: subprocess.Popen) -> None:
"""Terminate the command *and everything it spawned*.
Killing only the direct child leaves grandchildren holding the inherited
stdout/stderr pipe, which is what turns a bounded call back into a hang.
"""
if os.name == "nt":
try:
# /T walks the tree, /F forces. Bounded so the cleanup itself
# cannot become the new hang.
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
capture_output=True,
timeout=REAP_TIMEOUT,
)
except Exception: # pragma: no cover - Windows-only path
_kill_direct(proc)
return
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
# Already gone, or we could not resolve the group; fall back.
_kill_direct(proc)
def _kill_direct(proc: subprocess.Popen) -> None:
"""Last-resort kill of the direct child only."""
try:
proc.kill()
except Exception: # pragma: no cover - process already reaped
pass
def _reap(proc: subprocess.Popen) -> tuple[str, str]:
"""Collect whatever output is available without blocking indefinitely."""
try:
return proc.communicate(timeout=REAP_TIMEOUT)
except subprocess.TimeoutExpired:
# A descendant survived the group kill and still holds the pipe.
# Abandon the output rather than block the caller -- returning late
# is the failure mode this module exists to prevent.
logger.warning(
"Guarded command %s left a descendant holding its output pipe; "
"abandoning captured output after %.0fs.",
proc.args,
REAP_TIMEOUT,
)
for stream in (proc.stdout, proc.stderr):
try:
if stream is not None:
stream.close()
except Exception: # pragma: no cover - best effort
pass
return "", ""
except Exception: # pragma: no cover - defensive
return "", ""
def run_guarded(
cmd: Sequence[str],
*,
timeout: Optional[float] = None,
cwd: Optional[str] = None,
env: Optional[Mapping[str, str]] = None,
interactive: bool = False,
check: bool = False,
text: bool = True,
capture_output: bool = True,
**kwargs,
) -> subprocess.CompletedProcess:
"""Run ``cmd`` with a bounded runtime, never raising on timeout.
Behaves like ``subprocess.run(..., capture_output=True, text=True)``. On
timeout the whole process group is killed and a
:class:`subprocess.CompletedProcess` is returned with returncode
:data:`TIMEOUT_RETURNCODE` and a diagnosis appended to stderr, so existing
``returncode == 0`` checks keep working instead of having to grow a new
exception path.
Args:
cmd: Command and arguments.
timeout: Total seconds allowed. Defaults to
``PRAISONAI_SUBPROCESS_TIMEOUT`` or :data:`DEFAULT_TIMEOUT`.
cwd: Working directory.
env: Environment; prompt-suppressing defaults are layered underneath.
interactive: Set True only for a command that must reach a terminal.
Skips prompt suppression and stdin redirection; the timeout and
the group kill still apply.
check: Raise ``CalledProcessError`` on a non-zero exit, matching
``subprocess.run``. A timeout still returns rather than raising.
text: Decode output as text.
capture_output: Capture stdout and stderr.
**kwargs: Forwarded to ``subprocess.Popen``.
"""
resolved_timeout = _default_timeout() if timeout is None else timeout
if capture_output:
kwargs.setdefault("stdout", subprocess.PIPE)
kwargs.setdefault("stderr", subprocess.PIPE)
# stdin at /dev/null turns "wait forever for input" into an immediate EOF
# for tools that honour it, so the timeout is a backstop rather than the
# primary defence.
if not interactive:
kwargs.setdefault("stdin", subprocess.DEVNULL)
# Isolate the command so the whole tree can be killed together.
if os.name == "nt":
kwargs["creationflags"] = (
kwargs.get("creationflags", 0) | subprocess.CREATE_NEW_PROCESS_GROUP
)
else:
kwargs.setdefault("start_new_session", True)
argv = list(cmd)
proc = subprocess.Popen(
argv,
cwd=cwd,
env=env if interactive else _non_interactive_env(env),
text=text,
**kwargs,
)
try:
stdout, stderr = proc.communicate(timeout=resolved_timeout)
except subprocess.TimeoutExpired:
_kill_process_group(proc)
stdout, stderr = _reap(proc)
stdout = _as_text(stdout)
stderr = _as_text(stderr)
# Built outside the f-string: a backslash inside an f-string
# expression is a syntax error before Python 3.12, and this package
# supports 3.10+.
captured = stdout + "\n" + stderr
detail = (
f"Command killed after {resolved_timeout:g}s: {' '.join(argv)}\n"
f"{diagnose(captured)}"
)
return subprocess.CompletedProcess(
args=argv,
returncode=TIMEOUT_RETURNCODE,
stdout=stdout,
stderr=(stderr + "\n" + detail).strip(),
)
except BaseException:
# Never leave a running child behind on an unexpected error, including
# KeyboardInterrupt.
_kill_process_group(proc)
_reap(proc)
raise
result = subprocess.CompletedProcess(
args=argv,
returncode=proc.returncode,
stdout=_as_text(stdout) if capture_output else stdout,
stderr=_as_text(stderr) if capture_output else stderr,
)
if check and result.returncode != 0:
raise subprocess.CalledProcessError(
result.returncode, argv, result.stdout, result.stderr
)
return result
def _as_text(value) -> str:
"""Normalise stdout/stderr, which may be bytes, None, or already str."""
if value is None:
return ""
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
return str(value)
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.1"
__version__ = "0.1.2"
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
from .local import LocalBot
from .webhook import WebhookBot, WebhookRoute
from .bot import Bot
from .botos import BotOS
from .botos import BotOS, ThreadRef
from ._session import BotSessionManager
from ._identity import StoreBackedIdentityResolver
from ._agent_registry import AgentRegistry, normalize_number
@@ -90,6 +90,9 @@ def __getattr__(name: str):
if name == "BotOS":
from .botos import BotOS
return BotOS
if name == "ThreadRef":
from .botos import ThreadRef
return ThreadRef
if name == "SlackApproval":
from ._slack_approval import SlackApproval
return SlackApproval
@@ -217,7 +220,7 @@ __all__ = [
"SignalBot",
"LocalBot",
"WebhookBot", "WebhookRoute",
"Bot", "BotOS",
"Bot", "BotOS", "ThreadRef",
"BotSessionManager",
"StoreBackedIdentityResolver",
"AgentRegistry", "normalize_number",
@@ -0,0 +1,342 @@
"""Distributed per-turn lock for the gateway (Issue #4655).
The gateway's per-turn serialisation seam (``BotSessionManager._locks``) consumes
a ``LockMap``-shaped object: ``.get(key)`` returns something usable in
``async with`` that serialises turns for that key. The default in-process
``LockMap`` only serialises *within one process*, so a horizontally-scaled
gateway (``replicas > 1``) still runs concurrent turns against one resolved
session — the failure ``gateway.turn_lock.backend="redis"`` promises to prevent.
``RedisTurnLock`` is a drop-in, ``LockMap``-compatible replacement that acquires
a cluster-wide lease per resolved-session key using the scheduler's proven
owner+TTL lease pattern (``SET NX PX`` to claim, compare-and-del to release), so
a crashed holder's lease auto-expires after ``ttl`` and can never wedge a healthy
session. On any Redis outage it **fails open** (degrades to a local
``asyncio.Lock`` and records a ``DegradedCapabilityRegistry`` entry) rather than
wedging a live session — mirroring the fail-safe defaults elsewhere in the
gateway.
The heavy Redis dependency and connection lifecycle live here in the wrapper/bot
package; core only owns the protocol and config (``TurnLockProtocol`` /
``TurnLockConfig``), keeping the protocol-driven core dependency-free.
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from typing import Any, Optional
logger = logging.getLogger(__name__)
# ``owner_kind`` must be one of the core ``OWNER_KINDS`` (capability fits a
# gateway-internal serialisation control); ``owner_id`` names the mechanism.
_DEGRADED_OWNER_KIND = "capability"
_DEGRADED_OWNER_ID = "turn_lock:redis"
class _RedisLease:
"""Async context manager holding one distributed lease for a key.
Claims ``key`` with ``SET NX PX`` (owner token + TTL). On acquire failure it
polls until the current holder's lease is free or expires. On a Redis outage
it falls back to the local lock so a live turn is never wedged (fail-open).
Release is a compare-and-del on the owner token: an expired/reclaimed lease
is a harmless no-op, never another replica's lease.
"""
def __init__(self, lock: "RedisTurnLock", key: str) -> None:
self._lock = lock
self._redis_key = lock._redis_key(key)
self._local_key = key
self._token = uuid.uuid4().hex
self._local_lock: Optional[asyncio.Lock] = None
self._have_remote = False
self._renew_task: Optional[asyncio.Task] = None
async def __aenter__(self) -> "_RedisLease":
# Always hold the in-process lock too: it serialises same-process
# waiters cheaply and is the fail-open fallback when Redis is down.
self._local_lock = self._lock._local.get(self._local_key)
await self._local_lock.acquire()
# Any failure or cancellation after this point (e.g. BotOS.stop cancels
# in-flight bot tasks during the drain window) must release the local
# lock — otherwise the held asyncio.Lock is never evicted and every later
# turn for this session blocks forever in-process.
try:
ttl_ms = max(1, int(self._lock._ttl * 1000))
poll = self._lock._poll_interval
while True:
try:
ok = await self._lock._client.set(
self._redis_key, self._token, nx=True, px=ttl_ms
)
except Exception as e: # Redis outage -> fail open on local lock.
self._lock._mark_degraded(str(e))
self._have_remote = False
return self
if ok:
self._lock._clear_degraded()
self._have_remote = True
# Renew the lease while the turn runs so a turn longer than
# ``ttl`` never lets another replica claim the same session.
self._renew_task = asyncio.ensure_future(self._renew_loop())
return self
# Held by another replica; poll until its lease frees or expires
# (the holder's ``px`` TTL guarantees this loop is bounded).
await asyncio.sleep(poll)
except BaseException:
if self._local_lock.locked():
self._local_lock.release()
raise
async def _renew_loop(self) -> None:
"""Extend our lease at ~ttl/3 so a long turn keeps the session (owner-checked)."""
interval = max(self._lock._poll_interval, self._lock._ttl / 3.0)
ttl_ms = max(1, int(self._lock._ttl * 1000))
try:
while True:
await asyncio.sleep(interval)
try:
await self._lock._renew_remote(
self._redis_key, self._token, ttl_ms
)
except Exception as e: # pragma: no cover - best-effort renewal
logger.debug("Failed to renew redis turn lease: %s", e)
except asyncio.CancelledError: # pragma: no cover - normal teardown
pass
async def __aexit__(self, exc_type, exc, tb) -> None:
try:
if self._renew_task is not None:
self._renew_task.cancel()
try:
await self._renew_task
except (asyncio.CancelledError, Exception): # pragma: no cover
pass
if self._have_remote:
try:
await self._lock._release_remote(self._redis_key, self._token)
except Exception as e: # pragma: no cover - best-effort release
logger.debug("Failed to release redis turn lease: %s", e)
finally:
if self._local_lock is not None and self._local_lock.locked():
self._local_lock.release()
class RedisTurnLock:
"""Distributed, ``LockMap``-compatible per-turn lock (Issue #4655).
Exposes the same ``.get(key)`` / ``.drop(key)`` / ``.size()`` surface as
:class:`~praisonai_bot._lockmap.LockMap` so it drops into the existing
``BotSessionManager._locks`` seam with no session-layer change. Each
``.get(key)`` returns an async context manager that holds a cluster-wide
lease for the resolved-session key while a turn runs.
"""
# Lua: delete only if the value still equals our owner token (atomic
# compare-and-del), so we never release a lease another replica reclaimed.
_RELEASE_LUA = (
"if redis.call('get', KEYS[1]) == ARGV[1] then "
"return redis.call('del', KEYS[1]) else return 0 end"
)
# Lua: extend the TTL only if the value still equals our owner token, so a
# lease another replica reclaimed after expiry is never extended.
_RENEW_LUA = (
"if redis.call('get', KEYS[1]) == ARGV[1] then "
"return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end"
)
def __init__(
self,
client: Any,
*,
ttl: float = 60.0,
prefix: str = "praison:turnlock:",
poll_interval: float = 0.05,
degraded_registry: Optional[Any] = None,
) -> None:
from .._lockmap import LockMap
self._client = client
self._ttl = float(ttl)
self._prefix = prefix
self._poll_interval = poll_interval
self._local = LockMap()
self._degraded_registry = degraded_registry
self._degraded = False
def _redis_key(self, key: str) -> str:
return f"{self._prefix}{key}"
def get(self, key: str) -> _RedisLease:
"""Return an async context manager holding the lease for ``key``."""
return _RedisLease(self, str(key))
def drop(self, key: str) -> None:
"""Best-effort local cleanup; the remote lease expires via TTL."""
try:
self._local.drop(str(key))
except Exception: # pragma: no cover - defensive
pass
def size(self) -> int:
return self._local.size()
async def _release_remote(self, redis_key: str, token: str) -> None:
try:
await self._client.eval(self._RELEASE_LUA, 1, redis_key, token)
except Exception:
# Fallback for clients without eval: best-effort compare-and-del.
current = await self._client.get(redis_key)
if current == token:
await self._client.delete(redis_key)
async def _renew_remote(self, redis_key: str, token: str, ttl_ms: int) -> None:
"""Owner-checked lease extension (compare-and-PEXPIRE)."""
try:
await self._client.eval(self._RENEW_LUA, 1, redis_key, token, ttl_ms)
except Exception:
# Fallback for clients without eval: renew only if still ours.
current = await self._client.get(redis_key)
if current == token:
# Re-claim (SET without NX) preserving the owner token + TTL.
await self._client.set(redis_key, token, px=ttl_ms)
def _mark_degraded(self, reason: str) -> None:
"""Record a degraded turn-lock capability (Issue #4655 fail-open)."""
already = self._degraded
self._degraded = True
if not already:
logger.warning(
"Distributed turn lock degraded to local-only "
"(Redis unavailable): %s", reason
)
registry = self._degraded_registry
if registry is None:
return
try:
from praisonaiagents.gateway.degraded_state import DegradedOwner
registry.mark(
DegradedOwner(
owner_kind=_DEGRADED_OWNER_KIND,
owner_id=_DEGRADED_OWNER_ID,
state="stale",
reason=f"distributed turn lock unavailable: {reason}",
retry_hint=(
"check Redis connectivity; turns serialise per-process "
"only until it recovers; see `praisonai gateway doctor`"
),
)
)
except Exception as e: # pragma: no cover - registry is best-effort
logger.debug("Failed to mark turn-lock degraded: %s", e)
def _clear_degraded(self) -> None:
if not self._degraded:
return
self._degraded = False
registry = self._degraded_registry
if registry is None:
return
try:
registry.clear(_DEGRADED_OWNER_KIND, _DEGRADED_OWNER_ID)
except Exception as e: # pragma: no cover - registry is best-effort
logger.debug("Failed to clear turn-lock degraded: %s", e)
def build_turn_lock(
turn_lock_config: Optional[Any],
redis_config: Optional[Any] = None,
*,
degraded_registry: Optional[Any] = None,
) -> Any:
"""Select the per-turn lock backend from ``TurnLockConfig`` (Issue #4655).
Returns a ``LockMap``-compatible object. For the default ``"local"`` backend
(or when no config is given) this is a plain in-process ``LockMap`` — today's
behaviour, byte-for-byte. For ``"redis"`` it constructs a
:class:`RedisTurnLock` reusing the gateway's configured ``RedisConfig`` (or
the ``turn_lock.url`` override), so turns serialise cluster-wide.
Construction fails *open*: if Redis cannot be reached the caller gets a
degraded ``RedisTurnLock`` (local fallback + a ``DegradedCapabilityRegistry``
entry) rather than a silent downgrade — the outage surfaces in
``health()`` / ``doctor``.
"""
from .._lockmap import LockMap
enabled = bool(getattr(turn_lock_config, "enabled", False))
if turn_lock_config is None or not enabled:
return LockMap()
url = getattr(turn_lock_config, "url", None)
ttl = float(getattr(turn_lock_config, "ttl", 60.0))
try:
import redis.asyncio as aioredis
except ImportError:
logger.warning(
"gateway.turn_lock.backend='redis' selected but redis[asyncio] is "
"not installed; turns serialise per-process only. "
"Install with: pip install redis"
)
_record_build_failure(degraded_registry, "redis package not installed")
return LockMap()
try:
if url:
client = aioredis.from_url(url, decode_responses=True)
elif redis_config is not None and getattr(redis_config, "url", None):
client = aioredis.from_url(redis_config.url, decode_responses=True)
elif redis_config is not None:
client = aioredis.Redis(
host=getattr(redis_config, "host", "localhost"),
port=getattr(redis_config, "port", 6379),
db=getattr(redis_config, "db", 0),
password=getattr(redis_config, "password", None),
decode_responses=True,
)
else:
logger.warning(
"gateway.turn_lock.backend='redis' selected but no Redis URL or "
"RedisConfig is available; turns serialise per-process only."
)
_record_build_failure(degraded_registry, "no Redis connection configured")
return LockMap()
except Exception as e: # pragma: no cover - construction is defensive
logger.warning("Failed to build distributed turn lock: %s", e)
_record_build_failure(degraded_registry, str(e))
return LockMap()
prefix = getattr(redis_config, "prefix", "praison:") if redis_config else "praison:"
return RedisTurnLock(
client,
ttl=ttl,
prefix=f"{prefix}turnlock:",
degraded_registry=degraded_registry,
)
def _record_build_failure(registry: Optional[Any], reason: str) -> None:
if registry is None:
return
try:
from praisonaiagents.gateway.degraded_state import DegradedOwner
registry.mark(
DegradedOwner(
owner_kind=_DEGRADED_OWNER_KIND,
owner_id=_DEGRADED_OWNER_ID,
state="stale",
reason=f"distributed turn lock unavailable: {reason}",
retry_hint=(
"turns serialise per-process only; see "
"`praisonai gateway doctor`"
),
)
)
except Exception as e: # pragma: no cover - registry is best-effort
logger.debug("Failed to record turn-lock build failure: %s", e)
@@ -37,7 +37,7 @@ import asyncio
import logging
import time
from enum import Enum
from typing import Dict, Optional, Any, TYPE_CHECKING
from typing import Callable, Dict, Optional, Any, TYPE_CHECKING
from .._lockmap import LockMap
if TYPE_CHECKING:
@@ -46,6 +46,45 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def make_approval_supersede_callback(
manager: Optional[Any] = None,
) -> Callable[[str, int], None]:
"""Build an ``on_supersede`` callback that cancels a turn's gateway approvals.
Bridges :class:`SessionRunControl` to the gateway's ``ExecApprovalManager``
so a ``/stop`` (or an interrupting message) fail-closes any approval parked
on the abandoned turn: the awaiting tool call unwinds promptly and a
resolution that arrives after the stop is dropped.
The returned callback maps ``user_id`` → the approval manager's
``session_id`` (a bot user *is* the approval session in the single-agent bot
flow) and calls ``cancel_for_generation``. It is intentionally best-effort:
any failure (e.g. the gateway package or manager singleton being absent in a
lightweight deployment) is logged and swallowed so stop/interrupt handling is
never broken.
Args:
manager: Optional explicit ``ExecApprovalManager``. Resolved lazily from
the gateway singleton when ``None`` so importing this module never
pulls in the gateway.
"""
def _on_supersede(user_id: str, generation: int) -> None:
mgr = manager
try:
if mgr is None:
from ..gateway.exec_approval import get_exec_approval_manager
mgr = get_exec_approval_manager()
mgr.cancel_for_generation(user_id, generation)
except Exception: # noqa: BLE001 - callback must not break run control
logger.debug(
"Approval supersede callback no-op for user=%s generation=%s",
user_id, generation, exc_info=True,
)
return _on_supersede
class RunDecision(Enum):
"""Decision for how to handle a submitted message."""
RUN_NOW = "run_now" # No current run, start immediately
@@ -86,13 +125,21 @@ class SessionRunControl:
def __init__(
self,
busy_mode: str = "queue",
busy_ack_template: str = "⏳ {action} — will be considered next"
busy_ack_template: str = "⏳ {action} — will be considered next",
on_supersede: Optional[Callable[[str, int], Any]] = None,
):
"""Initialize session run control.
Args:
busy_mode: Policy for mid-run messages ("queue", "interrupt", "steer")
busy_ack_template: Template for busy acknowledgment messages
on_supersede: Optional callback invoked as ``(user_id, generation)``
whenever a run is stopped or interrupted, where ``generation`` is
the run generation being abandoned. The gateway wires this to
cancel pending approvals bound to that turn so a ``/stop`` (or a
superseding message) unblocks an approval-parked turn and drops
a resolution that arrives after the turn is gone. ``None``
(default) keeps today's behaviour (no approval cancellation).
"""
try:
self._busy_mode = BusyMode(busy_mode)
@@ -101,9 +148,26 @@ class SessionRunControl:
self._busy_mode = BusyMode.QUEUE
self._busy_ack_template = busy_ack_template
self._on_supersede = on_supersede
self._sessions: Dict[str, SessionRunState] = {}
self._locks = LockMap()
def _notify_supersede(self, user_id: str, generation: int) -> None:
"""Best-effort notify that ``generation`` for ``user_id`` was abandoned.
Failures in the callback must never break stop/interrupt handling, so
exceptions are logged and swallowed.
"""
if self._on_supersede is None:
return
try:
self._on_supersede(user_id, generation)
except Exception: # noqa: BLE001 - callback must not break run control
logger.exception(
"on_supersede callback failed for user=%s generation=%s",
user_id, generation,
)
def _get_lock(self, user_id: str) -> asyncio.Lock:
"""Get or create lock for user."""
return self._locks.get(user_id)
@@ -150,7 +214,12 @@ class SessionRunControl:
# Cancel current run and start new one
if session.interrupt_controller:
session.interrupt_controller.request("user_new_message")
# Cancel any pending approval bound to the turn being superseded
# so an approval-parked turn unwinds and a late resolution for it
# is dropped, before we advance the generation.
self._notify_supersede(user_id, session.run_generation)
session.pending_message = None # Clear any pending
session.run_generation += 1
session.start_time = time.time()
@@ -247,7 +316,12 @@ class SessionRunControl:
# Request interrupt
if session.interrupt_controller:
session.interrupt_controller.request("user_stop_command")
# Cancel any pending approval bound to this turn so a turn parked on
# a blocking approval wait unwinds promptly (making /stop visible)
# and a resolution that arrives after the stop is dropped.
self._notify_supersede(user_id, session.run_generation)
# Clear state
session.is_running = False
session.pending_message = None
@@ -194,6 +194,7 @@ class BotSessionManager:
platform: str = "",
dlq: Optional[Any] = None,
identity_resolver: Optional[Any] = None,
identity_canonicalizer: Optional[Any] = None,
ingress_journal: Optional[Any] = None,
run_control: Optional[Any] = None,
run_timeout: float = 300.0, # 5 minutes default timeout
@@ -245,6 +246,16 @@ class BotSessionManager:
# session key is the resolver-returned unified user id, so the
# same human pinging from multiple platforms shares one history.
self._identity_resolver = identity_resolver
# Issue #4661: optional identity-canonicalization seam. An
# ``IdentityCanonicalizerProtocol`` (``canonicalize(platform,
# raw_user_id) -> str`` — typically the active adapter itself, since
# ``BasePlatformAdapter`` implements it) consulted on the raw platform
# id *before* it enters the session key / resolver lookup, so a
# platform-side id change for the *same* human (WhatsApp JID→LID, a
# number↔UUID alias flip) collapses to one stable session instead of
# silently forking history/memory/run-state. ``None`` (default) is the
# identity function — behaviour is byte-for-byte unchanged.
self._identity_canonicalizer = identity_canonicalizer
# Ingress journal: optional durable message processing with dedup.
# When set, messages are journaled before agent processing for
# crash recovery and webhook redelivery protection.
@@ -409,6 +420,13 @@ class BotSessionManager:
identity_resolver = getattr(runtime, "identity_resolver", None)
if identity_resolver is not None:
self._identity_resolver = identity_resolver
# Issue #4661: adopt an identity-canonicalization seam when the runtime
# carries one (an ``IdentityCanonicalizerProtocol``, e.g. the active
# adapter itself), so a volatile platform id is stabilised before it
# keys a session. Absent seam leaves the identity default untouched.
identity_canonicalizer = getattr(runtime, "identity_canonicalizer", None)
if identity_canonicalizer is not None:
self._identity_canonicalizer = identity_canonicalizer
delivery_router = getattr(runtime, "delivery_router", None)
if delivery_router is not None:
self._delivery_router = delivery_router
@@ -578,6 +596,29 @@ class BotSessionManager:
return "per_user"
return "per_chat"
def _canonical_user_id(self, user_id: str) -> str:
"""Stabilise a volatile raw platform id before it keys a session.
Applies the optional identity-canonicalization seam (Issue #4661): an
``IdentityCanonicalizerProtocol`` (``canonicalize(platform,
raw_user_id) -> str`` — typically the active adapter, since
``BasePlatformAdapter`` implements it) wired in via
``__init__``/``attach_gateway_runtime``. Lets a platform-side id change
for the same human (WhatsApp JID→LID, a number↔UUID alias flip) collapse
to one stable session key instead of silently forking. No-op (identity)
when none is wired, and fail-open: any error falls back to the raw id so
a buggy canonicalizer can never break session keying.
"""
canonicalizer = self._identity_canonicalizer
if canonicalizer is None:
return user_id
try:
canonical = canonicalizer.canonicalize(self._platform, user_id)
except Exception as e: # pragma: no cover — defensive, never break keying
logger.warning("identity canonicalizer failed: %s", e)
return user_id
return canonical if isinstance(canonical, str) and canonical else user_id
def _storage_key(
self,
user_id: str,
@@ -618,14 +659,23 @@ class BotSessionManager:
prefix = self._platform or "bot"
account_key = account or "default"
base = f"{prefix}:acct:{account_key}:chat:{chat_id}:{thread_id}"
elif self._identity_resolver is not None and self._platform:
try:
base = self._identity_resolver.resolve(self._platform, user_id)
except Exception as e: # pragma: no cover — defensive
logger.warning("identity resolver failed: %s", e)
base = user_id
else:
base = user_id
# Issue #4661: canonicalise a volatile platform id to a stable one
# BEFORE it enters the session key or the link/pairing resolver, so
# a platform-side id change for the same human (WhatsApp JID→LID, a
# number↔UUID alias flip) resolves to one session rather than
# silently forking. No-op (identity) when no normalizer is wired.
canonical_user_id = self._canonical_user_id(user_id)
if self._identity_resolver is not None and self._platform:
try:
base = self._identity_resolver.resolve(
self._platform, canonical_user_id
)
except Exception as e: # pragma: no cover — defensive
logger.warning("identity resolver failed: %s", e)
base = canonical_user_id
else:
base = canonical_user_id
# Per-tenant memory isolation (Issue #4341): when a route resolved to a
# ``profile``, its namespace is bound turn-locally in ``_ACTIVE_PROFILE_NS``
# so every storage key is prefixed with the tenant scope. Reading the
@@ -2280,6 +2330,61 @@ class BotSessionManager:
"""List user IDs with active sessions."""
return list(self._histories.keys())
def export_history(self, storage_key: str) -> List[Dict[str, Any]]:
"""Return a copy of the transcript for a resolved *storage_key*.
Used by the gateway's live-session handoff (Issue #4660) to read the
current session's transcript so it can be seeded onto a freshly created
thread/DM on another platform. Prefers the durable store (so a
soft-evicted but persisted session is still exportable) and falls back
to the in-memory cache. Keys are the resolved storage keys reported by
:meth:`warm_sessions` / :meth:`get_user_ids` — no user-id resolution is
applied here, so the caller controls exactly which session moves.
"""
if self._store is not None:
try:
history = self._store.get_chat_history(self._persist_key(storage_key))
if history:
return list(history)
except Exception as e: # pragma: no cover — defensive
logger.warning("export_history store read failed: %s", e)
return list(self._histories.get(storage_key, []))
def seed_history(
self, storage_key: str, history: List[Dict[str, Any]]
) -> None:
"""Seed a resolved *storage_key* session with *history*.
The counterpart to :meth:`export_history` for live-session handoff
(Issue #4660): the destination session on the target platform is
primed with the origin transcript so the same conversation continues
seamlessly there. Writes both the in-memory cache and the durable
store (when configured) under the resolved key, mirroring
:meth:`_save_history` but keyed directly (no user-id resolution).
"""
history = list(history)
self._histories[storage_key] = history
if self._store is not None:
key = self._persist_key(storage_key)
self._flushed.discard(storage_key)
try:
if hasattr(self._store, "set_chat_history"):
self._store.set_chat_history(key, history)
else:
self._store.clear_session(key)
for msg in history:
self._store.add_message(
key,
msg.get("role", "user"),
msg.get("content", ""),
msg.get("metadata"),
)
except Exception as e: # pragma: no cover — defensive
logger.warning("seed_history store write failed: %s", e)
else:
self._flushed.add(storage_key)
self._last_active[storage_key] = time.monotonic()
def resolve_durable_store_dir(platform: str = ""):
"""Resolve the canonical per-platform SQLite store directory for durability.
@@ -714,6 +714,7 @@ class AgentMailBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
content: Union[str, Dict[str, Any]],
reply_to: Optional[str] = None,
thread_id: Optional[str] = None,
idempotency_key: Optional[str] = None,
**kwargs,
) -> BotMessage:
"""Send an email message.
@@ -723,6 +724,12 @@ class AgentMailBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
content: Message content (str or {"subject": ..., "body": ...})
reply_to: Message-ID to reply to
thread_id: Thread ID for conversation tracking
idempotency_key: Optional stable key for deterministic replay. Passed
as AgentMail's provider-level ``Idempotency-Key`` header so a
timeout-after-send never duplicates the email. Declared
explicitly (not via ``**kwargs``) so the durable-delivery layer
can detect and forward its persisted key. 1-256 chars; AgentMail
honours the key for 24h. Defaults to a generated UUID.
Returns:
The sent message
@@ -753,6 +760,17 @@ class AgentMailBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
client = self._get_client()
# Provider-level idempotency: derive ONE stable key per logical send so
# deliver_outbound()'s retries (default 3 attempts) never duplicate an
# email at the AgentMail boundary after a timeout-after-send. The same
# key must be reused for every retry, so it is generated here — outside
# _do_send() — and passed through request_options to both send/reply.
# Caller may supply idempotency_key to make replay deterministic; it must
# obey AgentMail's 1-256 char rule (24h key lifetime). Accept it via the
# explicit param or a legacy **kwargs entry, else generate a UUID.
idempotency_key = idempotency_key or kwargs.get("idempotency_key") or str(uuid.uuid4())
request_options = {"additional_headers": {"Idempotency-Key": idempotency_key}}
# Send via AgentMail API
# SDK v0.4.7: uses text= (not body=); reply_to= is Reply-To header
# For replying to a message, use messages.reply() method
@@ -764,12 +782,14 @@ class AgentMailBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
reply_to, # message_id of original message
text=body,
subject=subject,
request_options=request_options,
)
return client.inboxes.messages.send(
self._inbox_id,
to=channel_id,
subject=subject,
text=body,
request_options=request_options,
)
try:
+179 -6
View File
@@ -40,6 +40,7 @@ from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
@@ -54,6 +55,37 @@ from .delivery import DeliveryRouter, SessionSource
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ThreadRef:
"""A handle to a destination thread/DM created by a live-session handoff.
Returned by :meth:`BotOS.handoff` (Issue #4660). ``status`` is ``"ok"``
when the target thread was created and the session re-homed; otherwise it
carries the :meth:`DeliveryRouter.create_thread` outcome
(``"unsupported"`` / ``"failed"`` / ``"no_route"``) so a caller/chat
command can report *why* a handoff could not happen without exceptions.
``session_key`` is the resolved destination session key subsequent turns
on ``platform`` route to.
"""
platform: str
channel: str
thread_id: str
session_key: str
status: str = "ok"
@property
def ok(self) -> bool:
"""True when the handoff succeeded and the thread is ready."""
return self.status == "ok"
@property
def target(self) -> str:
"""The ``platform:channel:thread`` delivery target for the new thread."""
base = f"{self.platform}:{self.channel}"
return f"{base}:{self.thread_id}" if self.thread_id else base
def _coerce_drain_timeout(value: Optional[Any]) -> Optional[float]:
"""Coerce a drain-timeout config value to a finite float (or None).
@@ -117,6 +149,9 @@ class BotOS:
admission_policy: Optional[Any] = None,
max_rss_mb: float = 0.0,
reliability: Optional[str] = None,
turn_lock: Optional[Any] = None,
redis_config: Optional[Any] = None,
degraded_registry: Optional[Any] = None,
):
self._bots: Dict[str, Bot] = {}
self._is_running = False
@@ -192,17 +227,27 @@ class BotOS:
# W1: shared identity resolver applied to every managed bot —
# gives cross-platform unified-user sessions out of the box.
self._identity_resolver = identity_resolver
# Issue #3232: a single per-turn ``LockMap`` shared across every managed
# bot's session manager. Because each ``BotSessionManager`` keys its lock
# on the *resolved* session id (unified user id under an identity
# Issue #3232: a single per-turn lock shared across every managed bot's
# session manager. Because each ``BotSessionManager`` keys its lock on
# the *resolved* session id (unified user id under an identity
# resolver), sharing one map means two adapters that resolve to the same
# unified session hold the *same* lock and their turns run serially — no
# interleaved read-modify-write on one persisted transcript. Wired only
# when a resolver is configured (the sole case where distinct adapters
# unify to one session), so single-adapter behaviour is untouched.
from .._lockmap import LockMap
#
# Issue #4655: honour ``gateway.turn_lock.backend``. With the default
# ``"local"`` backend this stays a plain in-process ``LockMap`` (today's
# behaviour, byte-for-byte). With ``"redis"`` it becomes a
# ``RedisTurnLock`` so turns serialise cluster-wide across replicas
# rather than the config being silently inert.
self._turn_lock_config = turn_lock
self._degraded_registry = degraded_registry
from ._redis_turn_lock import build_turn_lock
self._turn_lock_map = LockMap()
self._turn_lock_map = build_turn_lock(
turn_lock, redis_config, degraded_registry=degraded_registry
)
self._tasks: List[asyncio.Task] = []
# Initialize delivery router for proactive outbound messaging
@@ -1067,7 +1112,13 @@ class BotOS:
getattr(bot, "_identity_resolver", None) is not None
for bot in self._bots.values()
)
if not has_resolver:
# Issue #4655: a distributed backend must serialise turns cluster-wide
# even for a single adapter (two *replicas* of one adapter still resolve
# to the same session). Wire it unconditionally in that case; the local
# backend keeps the resolver-only gate so single-adapter behaviour is
# unchanged.
distributed = bool(getattr(self._turn_lock_config, "enabled", False))
if not has_resolver and not distributed:
return
for platform, bot in self._bots.items():
try:
@@ -1178,6 +1229,128 @@ class BotOS:
"""
return await self._delivery_router.deliver(target, text, origin)
async def handoff(
self,
session_key: str,
to: str,
*,
name: str = "",
seed_text: Optional[str] = None,
announce: bool = True,
origin: Optional[SessionSource] = None,
) -> ThreadRef:
"""Re-home a live session onto a fresh thread/DM on another platform.
The gateway's live-session handoff (Issue #4660): "start on one device,
continue on another". It orchestrates existing gateway primitives —
it does **not** add new per-platform behaviour:
1. Creates a fresh thread on the ``to`` target via the existing
:meth:`DeliveryRouter.create_thread` (capability-gated per adapter).
2. Seeds that thread's session with the **current** transcript of
``session_key`` via the session store, so the same conversation
continues seamlessly there (:meth:`BotSessionManager.export_history`
→ :meth:`~BotSessionManager.seed_history`).
3. Subsequent turns arriving on the new thread route to the seeded
destination session through the normal inbound path.
4. Optionally drops a breadcrumb into the origin (``announce``) and a
seed message into the new thread (``seed_text``).
Never raises for an unsupported/unreachable target — the returned
:class:`ThreadRef` carries a non-``ok`` ``status`` so a chat command
(``/handoff <platform>``) can explain why the move did not happen.
Args:
session_key: The resolved session key to move (as reported by
:meth:`BotSessionManager.warm_sessions` / ``get_user_ids``).
to: Destination target (``platform`` | ``platform:channel`` |
alias), resolved by the delivery router.
name: Optional thread name; defaults to a handoff label.
seed_text: Optional first message posted into the new thread.
announce: When True (default) leave a breadcrumb in ``origin``.
origin: Optional source of the original request (for breadcrumb /
target resolution).
Returns:
A :class:`ThreadRef` describing the destination thread and the
destination session key subsequent turns route to.
"""
thread_name = name or "Handoff"
status, resolved, thread_id = await self._delivery_router.create_thread(
to, thread_name, origin
)
platform, _, channel = resolved.partition(":")
if status != "ok":
return ThreadRef(
platform=platform or to,
channel=channel,
thread_id="",
session_key="",
status=status,
)
# Seed the destination session with the origin transcript so the same
# conversation continues on the new thread. Both sessions are located
# by reusing the existing session-manager discovery; a missing manager
# or empty transcript is non-fatal (the thread is still created).
dest_session_key = f"{platform}:{channel}:{thread_id}"
try:
history = self._export_session_history(session_key)
if history:
dest_bot = self.get_bot(platform)
dest_session = (
self._find_session_manager(dest_bot) if dest_bot else None
)
if dest_session is not None and hasattr(dest_session, "seed_history"):
dest_session.seed_history(dest_session_key, history)
except Exception as e: # pragma: no cover — defensive, never break handoff
logger.warning("handoff session seed failed: %s", e)
# Post an optional first message into the new thread.
if seed_text:
try:
await self._delivery_router.deliver(dest_session_key, seed_text)
except Exception as e: # pragma: no cover — defensive
logger.debug("handoff seed_text delivery failed: %s", e)
# Drop a breadcrumb in the origin channel ("continued on <platform>").
if announce and origin is not None:
try:
await self._delivery_router.deliver(
"origin", f"Continued on {platform}.", origin
)
except Exception as e: # pragma: no cover — defensive
logger.debug("handoff breadcrumb delivery failed: %s", e)
return ThreadRef(
platform=platform,
channel=channel,
thread_id=thread_id,
session_key=dest_session_key,
status="ok",
)
def _export_session_history(self, session_key: str) -> List[Dict[str, Any]]:
"""Read a session's transcript from whichever bot owns *session_key*.
Scans registered bots' session managers for the transcript stored under
``session_key`` (its resolved storage key). Returns the first non-empty
transcript, or an empty list when no session manager holds it — so a
handoff of an unknown/empty session still creates the thread cleanly.
"""
for bot in self._bots.values():
session = self._find_session_manager(bot)
if session is None or not hasattr(session, "export_history"):
continue
try:
history = session.export_history(session_key)
except Exception as e: # pragma: no cover — defensive
logger.debug("export_history probe failed: %s", e)
continue
if history:
return history
return []
@property
def is_draining(self) -> bool:
"""Whether a graceful drain is currently in progress (#2375)."""
@@ -151,10 +151,14 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
run_control = None
if hasattr(self.config, 'busy_mode') and self.config.busy_mode != "queue":
try:
from ._run_control import SessionRunControl
from ._run_control import (
SessionRunControl,
make_approval_supersede_callback,
)
run_control = SessionRunControl(
busy_mode=self.config.busy_mode,
busy_ack_template=getattr(self.config, 'busy_ack', "⏳ {action} — will be considered next")
busy_ack_template=getattr(self.config, 'busy_ack', "⏳ {action} — will be considered next"),
on_supersede=make_approval_supersede_callback(),
)
except ImportError:
logger.warning("Run control not available, falling back to basic session management")
@@ -110,19 +110,83 @@ class GatewayApiEndpoints:
aid = agents[0]
return aid, self._gw.get_agent(aid)
async def _dispatch(self, session: Any, agent: Any, content: str) -> str:
"""Run one agent turn through the same admission gate as chat users."""
async def _dispatch(self, session: Any, agent: Any, content: str) -> tuple:
"""Run one agent turn through the same admission gate as chat users.
Returns ``(reply, usage)`` where ``usage`` is a snapshot of the agent's
per-turn token metrics captured **in the same execution context that
produced the turn's result**.
One ``Agent`` instance can serve overlapping turns for several sessions.
The SDK stores per-turn counts on a *shared, mutable*
``llm.last_token_metrics`` reference that each turn overwrites, so any
read that is not bound to the completing turn's own context can report a
different concurrent request's counts. The native async path runs on the
single event loop (no cross-turn interleaving without an ``await``), but
the sync ``chat`` fallback runs in a worker **thread**, so a bare read
after the ``await`` could still observe another thread's overwrite
(Greptile/Qodo P1). We therefore hand ``_dispatch_agent_turn`` an
``on_complete`` callback that snapshots the metrics *inside that same
context* — the worker thread for sync agents, the loop tick for async —
before it can be clobbered.
"""
holder: dict = {}
def _capture(a: Any) -> None:
holder["usage"] = self._usage_for(a)
gate = getattr(self._gw, "_admission_gate", None)
if gate is not None and getattr(gate, "enabled", False):
from ..bots._admission import AdmissionRejected
try:
async with gate.admit(session_id=session.session_id):
result = await self._gw._dispatch_agent_turn(agent, content)
result = await self._gw._dispatch_agent_turn(
agent, content, on_complete=_capture
)
except AdmissionRejected as rej:
return str(rej.message)
return str(rej.message), self._zero_usage()
else:
result = await self._gw._dispatch_agent_turn(agent, content)
return "" if result is None else str(result)
result = await self._gw._dispatch_agent_turn(
agent, content, on_complete=_capture
)
usage = holder.get("usage") or self._zero_usage()
return ("" if result is None else str(result)), usage
@staticmethod
def _zero_usage() -> dict:
return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
@staticmethod
def _usage_for(agent: Any) -> dict:
"""Snapshot the agent's last-turn token metrics as an OpenAI ``usage`` block.
The core SDK records true per-turn counts on the LLM instance
(``last_token_metrics`` with ``input_tokens``/``output_tokens``). We read
them straight off the boundary so cost-accounting clients get real
figures instead of the previous hardcoded zeros. When metrics are
unavailable (e.g. a custom agent that does not expose an LLM instance)
we fall back to zeros so the response stays spec-shaped.
Call sites MUST invoke this immediately after the dispatched turn
completes with no intervening ``await`` (see ``_dispatch``); the value
is read into plain ints here so the returned dict is an immutable
snapshot that a later concurrent turn cannot mutate.
"""
zero = GatewayApiEndpoints._zero_usage()
# Read the already-created LLM instance without forcing lazy creation.
llm = getattr(agent, "_llm_instance", None) or getattr(
agent, "llm_instance", None
)
metrics = getattr(llm, "last_token_metrics", None) if llm else None
if metrics is None:
return zero
prompt = int(getattr(metrics, "input_tokens", 0) or 0)
completion = int(getattr(metrics, "output_tokens", 0) or 0)
return {
"prompt_tokens": prompt,
"completion_tokens": completion,
"total_tokens": prompt + completion,
}
def _session_for(self, agent_id: str, key: str) -> Any:
"""Get or create a stable session keyed by the API caller."""
@@ -210,9 +274,15 @@ class GatewayApiEndpoints:
completion_id = "chatcmpl-" + uuid.uuid4().hex
if stream:
return self._sse_chat(agent_id, agent, session, content, completion_id)
opts = body.get("stream_options")
include_usage = bool(
isinstance(opts, dict) and opts.get("include_usage")
)
return self._sse_chat(
agent_id, agent, session, content, completion_id, include_usage
)
reply = await self._dispatch(session, agent, content)
reply, usage = await self._dispatch(session, agent, content)
return JSONResponse(
{
"id": completion_id,
@@ -226,15 +296,13 @@ class GatewayApiEndpoints:
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
},
"usage": usage,
}
)
def _sse_chat(self, agent_id, agent, session, content, completion_id):
def _sse_chat(
self, agent_id, agent, session, content, completion_id, include_usage=False
):
"""Emit a spec-shaped SSE chat-completion stream.
The frames are correctly ``chat.completion.chunk`` shaped and terminate
@@ -245,6 +313,11 @@ class GatewayApiEndpoints:
is deliberately out of scope here to avoid a hot-path regression. Latency
to first *content* therefore matches non-streaming; response correctness
and client compatibility are unaffected.
When the client sets ``stream_options.include_usage`` (the OpenAI
opt-in for streamed usage), a final ``usage``-only chunk carrying the
real per-turn token counts is emitted before ``[DONE]``, matching the
OpenAI streaming contract.
"""
from starlette.responses import StreamingResponse
@@ -261,7 +334,7 @@ class GatewayApiEndpoints:
}
yield f"data: {json.dumps(first)}\n\n"
reply = await self._dispatch(session, agent, content)
reply, usage = await self._dispatch(session, agent, content)
chunk = {
"id": completion_id,
"object": "chat.completion.chunk",
@@ -281,6 +354,18 @@ class GatewayApiEndpoints:
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
}
yield f"data: {json.dumps(final)}\n\n"
if include_usage:
usage_chunk = {
"id": completion_id,
"object": "chat.completion.chunk",
"created": created,
"model": agent_id,
"choices": [],
"usage": usage,
}
yield f"data: {json.dumps(usage_chunk)}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(gen(), media_type="text/event-stream")
@@ -310,7 +395,7 @@ class GatewayApiEndpoints:
raw = body.get("input", "")
content = raw if isinstance(raw, str) else _extract_text(raw)
session = self._session_for(agent_id, self._caller_key(request))
reply = await self._dispatch(session, agent, content)
reply, usage = await self._dispatch(session, agent, content)
return JSONResponse(
{
@@ -328,6 +413,11 @@ class GatewayApiEndpoints:
}
],
"output_text": reply,
"usage": {
"input_tokens": usage["prompt_tokens"],
"output_tokens": usage["completion_tokens"],
"total_tokens": usage["total_tokens"],
},
}
)
@@ -403,7 +493,7 @@ class GatewayApiEndpoints:
return err(-32602, f"Unknown agent/tool: {name}")
content = str(arguments.get("message", ""))
session = self._session_for(agent_id, self._caller_key(request))
reply = await self._dispatch(session, agent, content)
reply, _usage = await self._dispatch(session, agent, content)
return ok(
{
"content": [{"type": "text", "text": reply}],
@@ -106,6 +106,13 @@ class PendingRequest:
# this request. ``None`` (default) keeps today's behaviour where any holder
# of the APPROVALS scope may resolve any request.
authorized_reviewers: Optional[frozenset] = None
# Optional turn-liveness binding. ``session_id`` and ``run_generation``
# stamp the originating turn so a supersede/stop can cancel this pending
# approval, and ``resolve()`` can drop a resolution that arrives after the
# turn is gone (fail-closed). ``None`` (default) preserves today's behaviour
# where an approval is unbound and always considered live.
session_id: Optional[str] = None
run_generation: Optional[int] = None
# Resolved via the asyncio Future
_future: Optional[asyncio.Future] = field(default=None, repr=False)
# Store the event loop at registration time for thread-safe resolution
@@ -603,6 +610,11 @@ class ExecApprovalManager:
# weak task tracking cannot GC them mid-execution (see CPython docs
# for asyncio.create_task).
self._bg_tasks: Set[asyncio.Task] = set()
# Highest superseded run generation seen per session. A pending approval
# bound to a generation <= this value is stale (its turn was stopped or
# replaced). Sessions with no entry are fully live. Kept tiny — one int
# per active session — and pruned lazily as sessions come and go.
self._superseded_generation: Dict[str, int] = {}
self.allowlist = allowlist or PermissionAllowlist(
durable=durable, path=allowlist_path,
)
@@ -671,6 +683,8 @@ class ExecApprovalManager:
agent_name: str = "",
risk_level: str = "medium",
authorized_reviewers: Optional[Iterable[str]] = None,
session_id: Optional[str] = None,
run_generation: Optional[int] = None,
) -> tuple:
"""Create a pending request and return ``(request_id, future)``.
@@ -681,6 +695,12 @@ class ExecApprovalManager:
``authorized_reviewers`` optionally binds the request to a set of
reviewer identities; only those principals may resolve it. ``None``
(default) leaves resolution open to any holder of the APPROVALS scope.
``session_id``/``run_generation`` optionally bind the request to the
liveness of the originating turn. When supplied, :meth:`resolve` drops a
resolution that arrives after the turn was superseded/stopped, and
:meth:`cancel_for_generation` can fail-close the pending future when the
turn is interrupted. Both ``None`` (default) preserve today's behaviour.
"""
# Fast path: already permanently allowed (scoped to this agent, its
# argument signature, or a legacy global grant).
@@ -694,6 +714,24 @@ class ExecApprovalManager:
future.set_result(Resolution(approved=True, reason="allow-always"))
return ("auto", future)
# Defensive: a session-bound request must carry an integer generation.
# A non-int would raise during the ``<=`` comparison in
# cancel_for_generation()/_is_generation_live(), which callers swallow —
# leaving the approval silently un-cancellable. Coerce a clean int or
# drop the (untrustworthy) binding fail-open to unbound rather than
# register something that can never be superseded.
if session_id is not None and run_generation is not None:
if isinstance(run_generation, bool) or not isinstance(run_generation, int):
try:
run_generation = int(run_generation)
except (TypeError, ValueError):
logger.warning(
"Ignoring non-integer run_generation %r for session %s; "
"registering approval unbound.",
run_generation, session_id,
)
run_generation = None
request_id = self._make_id()
loop = asyncio.get_running_loop()
future = loop.create_future()
@@ -706,11 +744,32 @@ class ExecApprovalManager:
agent_name=agent_name,
risk_level=risk_level,
authorized_reviewers=reviewers,
session_id=session_id,
run_generation=run_generation,
_future=future,
_loop=loop, # capture loop at registration for thread-safe resolve
)
with self._lock:
# Fail-closed: if this turn's generation was already superseded (a
# /stop or interrupt raced ahead of registration), never park the
# caller on a future no cancel batch will ever complete — resolve it
# denied immediately and skip insertion.
if (
session_id is not None
and run_generation is not None
and not self._is_generation_live_locked(session_id, run_generation)
):
future.set_result(
Resolution(approved=False, reason="superseded")
)
logger.info(
"Approval request %s denied at registration: session=%s "
"generation=%s already superseded",
request_id, session_id, run_generation,
)
return (request_id, future)
self._prune()
self._pending[request_id] = req
@@ -772,6 +831,34 @@ class ExecApprovalManager:
)
return False
# Turn-liveness revalidation (fail-closed). If this request was bound to
# a turn that has since been superseded/stopped, drop the resolution:
# never execute the approved tool or deliver a reply for an abandoned
# turn. The pending future is already gone (popped above); complete it
# as denied so any awaiting caller unwinds. Requests with no liveness
# binding (session_id/run_generation ``None``) are always considered
# live, preserving today's behaviour.
if req.session_id is not None and not self._is_generation_live(
req.session_id, req.run_generation
):
logger.warning(
"Dropping stale approval resolution for %s (tool=%s): turn "
"session=%s generation=%s is no longer current",
request_id, req.tool_name, req.session_id, req.run_generation,
)
if req._future and not req._future.done() and req._loop:
req._loop.call_soon_threadsafe(
req._future.set_result,
Resolution(approved=False, reason="superseded"),
)
self._record_resolution(
request_id,
Resolution(approved=False, reason="superseded",
resolver=resolution.resolver),
approver=resolution.resolver or "gateway",
)
return False
approver = resolution.resolver or "gateway"
if resolution.allow_always:
@@ -891,6 +978,90 @@ class ExecApprovalManager:
with self._lock:
return self._pending.pop(request_id, None) is not None
# ── Turn-liveness binding ─────────────────────────────────────────
def _is_generation_live_locked(
self, session_id: str, run_generation: Optional[int]
) -> bool:
"""``_is_generation_live`` body, callable while already holding ``_lock``.
Split out so :meth:`register` can perform the liveness check inside its
own critical section (avoiding a lock re-entry / TOCTOU window) while
:meth:`resolve` keeps using the lock-acquiring wrapper.
"""
if run_generation is None:
return True
superseded = self._superseded_generation.get(session_id)
return superseded is None or run_generation > superseded
def _is_generation_live(
self, session_id: str, run_generation: Optional[int]
) -> bool:
"""Return whether ``run_generation`` for ``session_id`` is still current.
An approval is live unless its generation was explicitly superseded via
:meth:`cancel_for_generation`. Callers pass ``session_id is None`` for
unbound requests and never reach here (always live).
"""
if run_generation is None:
return True
with self._lock:
return self._is_generation_live_locked(session_id, run_generation)
def cancel_for_generation(
self, session_id: str, run_generation: int
) -> int:
"""Fail-closed cancel every pending approval bound to a superseded turn.
Marks generations ``<= run_generation`` for ``session_id`` as superseded
so any *later*-arriving resolution is dropped by :meth:`resolve`, and
completes any currently-pending future as denied so the awaiting tool
call unwinds promptly (this is what makes ``/stop`` produce a visible
outcome instead of parking indefinitely on the approval wait).
Returns the number of pending approvals cancelled. A no-op for sessions
with no bound pending approvals.
"""
with self._lock:
prev = self._superseded_generation.get(session_id)
if prev is None or run_generation > prev:
self._superseded_generation[session_id] = run_generation
to_cancel = [
r for r in self._pending.values()
if r.session_id == session_id
and r.run_generation is not None
and r.run_generation <= run_generation
]
for r in to_cancel:
self._pending.pop(r.request_id, None)
for r in to_cancel:
if r._future and not r._future.done() and r._loop:
r._loop.call_soon_threadsafe(
r._future.set_result,
Resolution(approved=False, reason="cancelled"),
)
self._record_resolution(
r.request_id,
Resolution(approved=False, reason="cancelled"),
approver="gateway",
)
if to_cancel:
logger.info(
"Cancelled %d pending approval(s) for session=%s generation<=%s",
len(to_cancel), session_id, run_generation,
)
return len(to_cancel)
def forget_session(self, session_id: str) -> None:
"""Drop the superseded-generation marker for a finished session.
Bounds the tiny per-session bookkeeping so it cannot grow unbounded as
sessions churn. Safe no-op when the session is unknown.
"""
with self._lock:
self._superseded_generation.pop(session_id, None)
# ── Query API ─────────────────────────────────────────────────────
def list_pending(self) -> List[Dict[str, Any]]:
@@ -84,12 +84,22 @@ class GatewayApprovalBackend:
Satisfies :class:`praisonaiagents.approval.protocols.ApprovalProtocol`.
"""
# Bind this pending approval to the originating turn's liveness when the
# caller stamped a session id / run generation (the latter travels in
# ``context`` to avoid widening the core contract). Both optional and a
# no-op when absent, preserving today's unbound behaviour.
run_generation = None
if isinstance(request.context, dict):
run_generation = request.context.get("run_generation")
request_id, future = await self.manager.register(
tool_name=request.tool_name,
arguments=request.arguments,
agent_name=request.agent_name or "",
risk_level=request.risk_level,
authorized_reviewers=request.authorized_reviewers,
session_id=request.session_id,
run_generation=run_generation,
)
# Fire optional notification (fire-and-forget)
@@ -8,14 +8,42 @@ system and follows Hermes patterns for worker management.
import asyncio
import logging
import math
import os
import re
import subprocess
import time
from typing import Dict, Any, List, Optional
from .._guarded_subprocess import run_guarded
logger = logging.getLogger(__name__)
# Ceiling for a single git invocation. Worktree add, merge and commit on a
# large repository are legitimately slow, so this is set well above the
# general default: the aim is to bound an indefinite hang, not to fail slow
# work. Override with PRAISONAI_KANBAN_GIT_TIMEOUT (seconds).
_DEFAULT_GIT_TIMEOUT = 300.0
def _git_timeout() -> float:
"""Resolve the per-git-command ceiling, ignoring an unusable override.
``inf`` is rejected as well as zero and negatives: it is positive and
parses cleanly, so accepting it would silently restore the unbounded git
call this guard exists to remove.
"""
raw = os.environ.get("PRAISONAI_KANBAN_GIT_TIMEOUT")
if not raw:
return _DEFAULT_GIT_TIMEOUT
try:
value = float(raw)
except (TypeError, ValueError):
return _DEFAULT_GIT_TIMEOUT
if not math.isfinite(value) or value <= 0:
return _DEFAULT_GIT_TIMEOUT
return value
# Global dispatcher state
_dispatcher_running = False
_dispatcher_task = None
@@ -415,12 +443,22 @@ class KanbanDispatcher:
A thin subprocess wrapper so worktree isolation stays self-contained in
the wrapper without pulling in a Tier-2 package dependency.
Bounded via :func:`run_guarded`: the dispatcher calls this from inside
the gateway's event loop, so a git command blocked on a credential
prompt or an ``index.lock`` would otherwise stall every WebSocket
client indefinitely. On timeout the result carries returncode 124 and
a diagnosis in stderr, which the existing ``returncode == 0`` checks
already treat as failure.
The ceiling is deliberately generous — worktree creation and merges on
a large repository are legitimately slow, and the goal is to bound a
hang, not to fail slow-but-healthy work.
"""
return subprocess.run(
return run_guarded(
["git", *args],
capture_output=True,
text=True,
cwd=cwd or self._repo_dir(),
timeout=_git_timeout(),
)
def _worktree_root(self) -> str:
@@ -0,0 +1,271 @@
"""Durable, boot-scoped process-lifecycle ledger for the gateway (Issue #4603).
The core SDK ships only the pure contract and decisions
(:class:`praisonaiagents.gateway.LifecycleRecord`,
:func:`praisonaiagents.gateway.classify_unclean_exit`,
:class:`praisonaiagents.gateway.PersistentRestartLoopGuard`,
:func:`praisonaiagents.gateway.classify_resource_pressure`). This wrapper module
owns the heavyweight, OS-specific mechanism those helpers deliberately avoid:
* a small durable sentinel file the runtime restamps on boot / shutdown and
folds a memory sample into on every heartbeat, so the *next* boot can detect
that the previous process died uncleanly (OOM-kill / ``SIGKILL`` run no
handler, so signal-based forensics never fire for them);
* ``/proc`` + cgroup memory sampling and ``shutil.disk_usage`` disk headroom so
``GET /health`` can classify redaction-safe memory/disk *pressure*; and
* a :class:`praisonaiagents.gateway.RestartStoreProtocol` backed by the same
sentinel so the restart-loop breaker survives the very restart it guards.
Everything here is best-effort and defensive: a sample must never raise and a
persistence failure must degrade to in-memory behaviour rather than crash the
gateway it is meant to protect.
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import time
from typing import Any, Dict, List, Optional
from praisonaiagents.gateway import (
LifecycleRecord,
PersistentRestartLoopGuard,
classify_resource_pressure,
classify_unclean_exit,
)
from .memory_pressure import CgroupMemoryPressure
logger = logging.getLogger(__name__)
class LifecycleLedger:
"""Durable process-lifecycle record maintained by the gateway runtime.
On boot the ledger reads the prior sentinel, classifies whether the previous
process exited cleanly, and restamps ``phase="running"`` (see
:meth:`record_boot`). A cheap :meth:`sample_memory` folds the current
memory-budget fraction into the record so a *later* unclean death is flagged
``suspected_oom``. :meth:`mark_exited` stamps a clean exit on the graceful
stop path only.
Args:
path: Sentinel file path. Its parent directory is created best-effort.
memory_budget_fraction: Budget fraction at/above which memory pressure
is classified ``critical`` (also the OOM-suspicion threshold).
disk_min_free_mb: Free-disk floor (MiB) below which disk is ``critical``.
"""
def __init__(
self,
path: str,
*,
memory_budget_fraction: float = 0.9,
disk_min_free_mb: float = 512.0,
):
self.path = path
self.memory_budget_fraction = float(memory_budget_fraction)
self.disk_min_free_mb = float(disk_min_free_mb)
self._mem = CgroupMemoryPressure()
self._record: LifecycleRecord = LifecycleRecord()
# Set on the first boot classification so /health can report the last
# exit without re-reading the file; None until record_boot() runs.
self._boot_exit_kind: Optional[str] = None
self._boot_suspected_oom: bool = False
try:
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
except OSError as exc: # pragma: no cover - defensive
logger.debug("lifecycle: could not prepare sentinel dir: %s", exc)
# ── persistence (best-effort) ──
def _read(self) -> Optional[LifecycleRecord]:
try:
with open(self.path, "r") as fh:
data = json.load(fh)
except (OSError, ValueError):
return None
return LifecycleRecord.from_dict(data)
def _write(self, record: LifecycleRecord) -> None:
"""Atomically persist ``record`` to the sentinel; never raises."""
tmp = f"{self.path}.tmp.{os.getpid()}"
try:
with open(tmp, "w") as fh:
json.dump(record.to_dict(), fh)
os.replace(tmp, self.path)
except OSError as exc: # pragma: no cover - defensive
logger.debug("lifecycle: could not persist sentinel: %s", exc)
try:
if os.path.exists(tmp):
os.remove(tmp)
except OSError:
pass
# ── lifecycle transitions ──
def record_boot(self) -> LifecycleRecord:
"""Read the prior sentinel, classify its exit, and restamp ``running``.
Returns the fresh boot record. When the previous process was still
``running`` in its last write, the record's ``exit_kind`` is
``"unclean"`` (and ``suspected_oom`` when its last memory sample was
high) — the boot-time signal that a signal-less OOM/``SIGKILL`` occurred.
"""
prev = self._read()
record = classify_unclean_exit(
prev, oom_fraction=self.memory_budget_fraction
)
self._boot_exit_kind = record.exit_kind
self._boot_suspected_oom = record.suspected_oom
# Stamp our own pid-owned boot marker so a graceful stop from *this*
# process can be told apart from a stale file another process left.
self._record = record
self._owner_pid = os.getpid()
self._write(record)
if record.exit_kind == "unclean":
logger.warning(
"Gateway previous process exited uncleanly%s "
"(no graceful-stop marker; suspected OOM/SIGKILL)",
" — suspected OOM" if record.suspected_oom else "",
)
return record
def sample_memory(self) -> Optional[float]:
"""Fold a fresh memory-budget fraction into the sentinel (<1ms).
Returns the sampled fraction (anon RSS / cgroup budget) in ``[0, ∞)`` or
``None`` when the platform has no cgroup limit to measure against. Cheap
enough to call from the heartbeat; persists the running record so a
subsequent hard-kill leaves the last sample behind for OOM suspicion.
"""
frac = self._memory_fraction()
record = LifecycleRecord(
phase="running",
exit_kind=self._record.exit_kind,
suspected_oom=self._record.suspected_oom,
last_mem_fraction=frac,
restart_events=list(self._record.restart_events),
)
self._record = record
self._write(record)
return frac
def mark_exited(self) -> None:
"""Stamp a clean exit — graceful-stop path only, ownership-checked.
Only the process that wrote the boot marker may record a clean exit, so
a late call from a forked/stale context cannot mask another process's
unclean death.
"""
if getattr(self, "_owner_pid", None) != os.getpid():
return
record = LifecycleRecord(
phase="exited",
exit_kind="clean",
suspected_oom=False,
last_mem_fraction=self._record.last_mem_fraction,
restart_events=list(self._record.restart_events),
)
self._record = record
self._write(record)
# ── health surface ──
def _memory_fraction(self) -> Optional[float]:
"""Anon RSS / cgroup budget fraction, or ``None`` when unmeasurable."""
try:
budget = self._mem.cgroup_limit_mb()
if not budget or budget <= 0:
return None
rss = self._mem.anon_rss_mb()
return float(rss) / float(budget)
except Exception:
return None
def _disk_free_mb(self) -> Optional[float]:
try:
usage = shutil.disk_usage(os.path.dirname(self.path) or ".")
return float(usage.free) / (1024.0 * 1024.0)
except Exception:
return None
def pressure(self) -> Dict[str, str]:
"""Return the coarse ``{"memory", "disk"}`` pressure block for /health."""
return classify_resource_pressure(
mem_fraction=self._memory_fraction(),
disk_free_mb=self._disk_free_mb(),
mem_critical=self.memory_budget_fraction,
disk_min_free_mb=self.disk_min_free_mb,
)
def last_exit(self) -> Dict[str, Any]:
"""Return the ``{"last_exit", "suspected_oom"}`` lifecycle block."""
return {
"last_exit": self._boot_exit_kind or "unknown",
"suspected_oom": bool(self._boot_suspected_oom),
}
class SentinelRestartStore:
"""A :class:`RestartStoreProtocol` backed by the lifecycle sentinel file.
Reads / writes only the ``restart_events`` slice of the durable
:class:`LifecycleRecord`, so the persistent restart-loop breaker shares one
file with the lifecycle ledger instead of introducing a second sentinel.
Best-effort on both ends: a read failure yields ``[]`` and a write failure
is swallowed, degrading the breaker to in-memory behaviour.
"""
def __init__(self, ledger: LifecycleLedger):
self._ledger = ledger
def load_events(self) -> List[float]:
record = self._ledger._read()
if record is None:
return list(self._ledger._record.restart_events)
return list(record.restart_events)
def save_events(self, events: List[float]) -> None:
rec = self._ledger._record
merged = LifecycleRecord(
phase=rec.phase,
exit_kind=rec.exit_kind,
suspected_oom=rec.suspected_oom,
last_mem_fraction=rec.last_mem_fraction,
restart_events=[float(t) for t in events],
)
self._ledger._record = merged
self._ledger._write(merged)
def default_sentinel_path() -> str:
"""Return the default lifecycle sentinel path under ``~/.praisonai``."""
return os.path.join(
os.path.expanduser("~"), ".praisonai", "gateway", "lifecycle.json"
)
def build_persistent_restart_guard(
ledger: LifecycleLedger,
*,
max_restarts: int = 3,
window_seconds: float = 3600.0,
) -> PersistentRestartLoopGuard:
"""Construct a durable restart-loop guard sharing the ledger's sentinel."""
return PersistentRestartLoopGuard(
SentinelRestartStore(ledger),
max_restarts=max_restarts,
window_seconds=window_seconds,
)
__all__ = [
"LifecycleLedger",
"SentinelRestartStore",
"default_sentinel_path",
"build_persistent_restart_guard",
]
@@ -111,7 +111,8 @@ def _restrict_windows_acl(secret_path: str) -> bool:
"""
try:
import getpass
import subprocess
from .._guarded_subprocess import run_guarded
user = os.environ.get("USERNAME") or getpass.getuser()
# Resolve icacls from the trusted system directory rather than PATH
@@ -123,7 +124,9 @@ def _restrict_windows_acl(secret_path: str) -> bool:
)
# Quote the username so principals containing spaces (e.g. "John Doe")
# are parsed as a single principal by icacls.
result = subprocess.run(
# Bounded: this runs while hardening the pairing secret on the gateway
# startup path, so a stalled icacls must not block it forever.
result = run_guarded(
[
icacls,
secret_path,
@@ -131,7 +134,7 @@ def _restrict_windows_acl(secret_path: str) -> bool:
"/grant:r",
f'"{user}":F',
],
capture_output=True,
timeout=15,
check=False,
)
return result.returncode == 0
@@ -879,7 +879,8 @@ def _read_env_file_tokens(path: str) -> Dict[str, str]:
def _scan_launch_agent(label: str) -> DuplicateService:
"""Inspect a macOS LaunchAgent plist by label."""
import plistlib
import subprocess
from .._guarded_subprocess import run_guarded
plist_path = os.path.expanduser(f"~/Library/LaunchAgents/{label}.plist")
service = DuplicateService(label=label, plist_path=plist_path)
@@ -899,11 +900,9 @@ def _scan_launch_agent(label: str) -> DuplicateService:
pass
try:
result = subprocess.run(
["launchctl", "list", label],
capture_output=True,
text=True,
)
# Bounded: preflight runs on the gateway's startup path, and a
# launchctl that stalls would hold up service discovery indefinitely.
result = run_guarded(["launchctl", "list", label], timeout=15)
service.running = result.returncode == 0
if service.running and result.stdout:
parts = result.stdout.strip().split("\t")
+348 -18
View File
@@ -1121,6 +1121,16 @@ class WebSocketGateway:
self._drain_marker_task: Optional[asyncio.Task] = None
self._lifecycle_drain_timeout: Optional[float] = None
# Issue #4603: durable, boot-scoped process-lifecycle ledger. Detects an
# unclean/OOM death of the *previous* process (which runs no signal
# handler, so forensics never fire), surfaces memory/disk pressure on
# ``/health``, and backs a restart-loop breaker that survives the very
# restart it guards. Off unless a ``lifecycle.ledger``/``health`` block
# enables it, so always-on gateways are unchanged.
self._lifecycle_ledger: Optional[Any] = None
self._ledger_heartbeat_task: Optional[asyncio.Task] = None
self._ledger_heartbeat_interval: float = 15.0
@property
def is_running(self) -> bool:
return self._is_running
@@ -2163,13 +2173,34 @@ class WebSocketGateway:
if self._session_store:
await self._start_session_cleanup()
# Issue #3379: re-drive any turn that was in-flight when a previous
# process restarted, and proactively notify the originating channel.
# Mirrors the durable approval rehydrate above; a no-op without a store.
try:
await self._resume_interrupted_turns()
except Exception:
logger.exception("Failed to resume interrupted turns on boot")
# Issue #4603: record this boot in the durable lifecycle ledger before
# anything else. This reads the prior sentinel, classifies whether the
# previous process exited cleanly (an unclean/OOM death runs no signal
# handler, so this is the only place it becomes visible), and restamps
# ``phase="running"``. No-op when the ledger is unconfigured.
if self._lifecycle_ledger is not None:
try:
self._lifecycle_ledger.record_boot()
except Exception:
logger.exception("Failed to record boot in lifecycle ledger")
# Issue #4603: if the persistent restart-loop breaker has already
# tripped across prior boots, skip the boot-time auto-resume re-drive
# (which is what keeps crashing the process) while still serving fresh
# inbound — instead of the supervisor respawning into the same crash.
if self._auto_resume_suspended():
logger.error(
"Persistent restart-loop breaker tripped; suspending boot-time "
"auto-resume of interrupted turns (still serving fresh inbound)"
)
else:
# Issue #3379: re-drive any turn that was in-flight when a previous
# process restarted, and proactively notify the originating channel.
# Mirrors the durable approval rehydrate above; a no-op without a store.
try:
await self._resume_interrupted_turns()
except Exception:
logger.exception("Failed to resume interrupted turns on boot")
# Issue #3881: drive recovery from the durable run ledger too. The
# session-blob scan above is heuristic; the ledger is the source of
@@ -2197,6 +2228,15 @@ class WebSocketGateway:
self._liveness_task = asyncio.create_task(
self._liveness_loop(), name="gateway-liveness"
)
# Issue #4603: fold a cheap ``/proc``+cgroup memory sample into the
# durable lifecycle sentinel on a slow cadence, so a later signal-less
# OOM/SIGKILL leaves the last memory reading behind for the next boot to
# flag ``suspected_oom``. No-op when the ledger is unconfigured.
if self._lifecycle_ledger is not None and self._ledger_heartbeat_task is None:
self._ledger_heartbeat_task = asyncio.create_task(
self._ledger_heartbeat_loop(), name="gateway-lifecycle-ledger"
)
try:
await self._server.serve()
except Exception as e:
@@ -2209,6 +2249,22 @@ class WebSocketGateway:
finally:
self._disarm_watchdog()
await self._cancel_liveness_task()
# Issue #4603: ``serve()`` returns through here on every graceful
# termination — the SIGINT/SIGTERM handler only flips
# ``should_exit`` and uvicorn unwinds, so ``stop()`` is not
# guaranteed to run. Stamp the clean-exit marker here (after
# cancelling the sampler so it cannot re-stamp ``running``) so a
# normal shutdown is not misreported as an unclean/OOM death on the
# next boot. ``mark_exited`` is pid-owned and idempotent, so a later
# explicit ``stop()`` is harmless.
await self._cancel_ledger_heartbeat()
if self._lifecycle_ledger is not None:
try:
self._lifecycle_ledger.mark_exited()
except Exception:
logger.debug(
"Failed to mark clean exit in lifecycle ledger (serve)"
)
async def _cancel_liveness_task(self) -> None:
"""Stop the connection-liveness heartbeat/reaper task (idempotent)."""
@@ -2223,6 +2279,95 @@ class WebSocketGateway:
except Exception:
logger.debug("Liveness task exited with error on cancel")
async def _cancel_ledger_heartbeat(self) -> None:
"""Stop the lifecycle-ledger memory-sample heartbeat (idempotent)."""
task = self._ledger_heartbeat_task
self._ledger_heartbeat_task = None
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except Exception:
logger.debug("Lifecycle-ledger heartbeat exited with error")
async def _ledger_heartbeat_loop(self) -> None:
"""Periodically fold a memory sample into the durable lifecycle sentinel.
Best-effort and cheap (<1ms of ``/proc``+cgroup I/O per tick). Runs only
while the ledger is configured; any error is swallowed so it never
crashes the gateway it is meant to make observable.
"""
ledger = self._lifecycle_ledger
if ledger is None:
return
interval = self._ledger_heartbeat_interval
while self._is_running:
try:
await asyncio.sleep(interval)
ledger.sample_memory()
except asyncio.CancelledError:
raise
except Exception:
logger.debug("Lifecycle-ledger sample failed", exc_info=True)
def _auto_resume_suspended(self) -> bool:
"""Whether the persistent restart-loop breaker has tripped this boot.
Only meaningful for a :class:`PersistentRestartLoopGuard` whose events
survive process boots. When there are interrupted turns to re-drive, a
boot is restart-worthy: record it and, once ``max_restarts`` such boots
fall within the window, report tripped so the caller skips the poison
re-drive that keeps crashing the process. Returns ``False`` for the
in-memory guard (which cannot see prior boots) and when unconfigured.
"""
guard = self._restart_loop_guard
if guard is None:
return False
# Only the persistent guard carries cross-boot state; the in-memory one
# is rebuilt fresh each boot and must not gate the resume path.
if not hasattr(guard, "_store"):
return False
# Nothing to re-drive ⇒ this boot is not restart-interrupted; don't
# count it against the breaker and let it re-arm on a clean boot.
if not self._has_interrupted_turns():
try:
guard.reset()
except Exception:
pass
return False
try:
return guard.record(now=time.time())
except Exception:
return False
def _has_interrupted_turns(self) -> bool:
"""Best-effort check for a persisted in-flight turn left by a restart."""
store = self._session_store
if not store:
return False
lister = getattr(store, "list_sessions", None)
if not callable(lister):
return False
try:
try:
summaries = lister(limit=1_000_000)
except TypeError:
summaries = lister()
except Exception:
return False
for summary in summaries or []:
sid = summary.get("session_id") if isinstance(summary, dict) else summary
if not sid:
continue
data = self._load_persisted_session_data(sid)
if not data:
continue
if bool(data.get("is_executing")) or bool(data.get("pending_inbox")):
return True
return False
# ── Event-loop liveness watchdog (Issue #3410) ──
def _configure_watchdog(self, watchdog_cfg: Optional[Dict[str, Any]]) -> None:
@@ -2383,6 +2528,58 @@ class WebSocketGateway:
logger.warning("Invalid restart_loop_guard config; disabling: %s", e)
self._restart_loop_guard = None
# Issue #4603: durable process-lifecycle ledger. Enabled via
# ``lifecycle.ledger.enabled`` (or implicitly when
# ``restart_loop_guard.persist`` is set) so an unclean/OOM prior death
# is detected at boot, pressure is surfaced on ``/health``, and — when
# ``restart_loop_guard.persist`` is truthy — the crash-loop breaker
# persists across boots and covers the auto-resume path. Off by default.
ledger_cfg = lifecycle_cfg.get("ledger")
if not isinstance(ledger_cfg, dict):
ledger_cfg = {}
persist_guard = isinstance(rlg, dict) and _as_bool(rlg.get("persist"))
ledger_enabled = _as_bool(ledger_cfg.get("enabled")) or persist_guard
if ledger_enabled:
try:
from .lifecycle_ledger import (
LifecycleLedger,
build_persistent_restart_guard,
default_sentinel_path,
)
health_cfg = lifecycle_cfg.get("health")
if not isinstance(health_cfg, dict):
health_cfg = {}
path = ledger_cfg.get("path") or default_sentinel_path()
self._lifecycle_ledger = LifecycleLedger(
str(path),
memory_budget_fraction=float(
health_cfg.get("memory_budget_fraction", 0.9)
),
disk_min_free_mb=float(
health_cfg.get("disk_min_free_mb", 512.0)
),
)
# When persistence is requested, upgrade the in-memory guard to
# one whose events survive the restart it guards and share the
# ledger's sentinel file.
if persist_guard:
rlg = rlg if isinstance(rlg, dict) else {}
self._restart_loop_guard = build_persistent_restart_guard(
self._lifecycle_ledger,
max_restarts=int(rlg.get("max_restarts", 3)),
window_seconds=float(
rlg.get("window_seconds", 3600.0)
),
)
logger.info(
"Gateway lifecycle ledger enabled (path=%s, persist_guard=%s)",
path, persist_guard,
)
except (ImportError, ValueError, OSError) as e:
logger.warning("Invalid lifecycle ledger config; disabling: %s", e)
self._lifecycle_ledger = None
def _merge_lifecycle_overrides(
self,
lifecycle_cfg: Optional[Dict[str, Any]],
@@ -2791,6 +2988,18 @@ class WebSocketGateway:
# Issue #2798/#3911: stop the connection-liveness heartbeat/reaper.
await self._cancel_liveness_task()
# Issue #4603: this is the graceful-stop path, so stamp a clean exit in
# the durable lifecycle sentinel before we go. Only the process that
# wrote the boot marker can do so (ownership-checked in the ledger), so
# a later unclean death of *another* boot is never masked. Cancel the
# sampler first so it cannot re-stamp ``phase="running"`` after this.
await self._cancel_ledger_heartbeat()
if self._lifecycle_ledger is not None:
try:
self._lifecycle_ledger.mark_exited()
except Exception:
logger.debug("Failed to mark clean exit in lifecycle ledger")
# Release PID lock
if hasattr(self, '_pid_lock') and self._pid_lock:
self._pid_lock.release_lock()
@@ -3302,7 +3511,7 @@ class WebSocketGateway:
@staticmethod
async def _dispatch_agent_turn(
agent: Any, content: str, interrupt: Any = None
agent: Any, content: str, interrupt: Any = None, on_complete: Any = None
) -> Any:
"""Execute a single agent turn.
@@ -3321,12 +3530,31 @@ class WebSocketGateway:
controller would let one session's abort/timeout interrupt another. A
legacy fallback stamps ``agent.interrupt_controller`` only for agents
whose entry point does not accept ``cancel_token``.
Issue #4656: ``on_complete(agent)`` — when supplied — is invoked
**synchronously in the same execution context that produced the turn's
result, before that context is handed back to the loop**. For a
sync-only agent this runs *inside the worker thread* right after
``agent.chat`` returns, so per-turn state the agent stores on shared,
mutable attributes (e.g. ``llm.last_token_metrics``) is snapshotted for
*this* turn before any concurrent turn — on another worker thread or a
later event-loop tick — can overwrite it. This closes the cross-request
usage-misattribution race for the sync ``chat`` fallback (Greptile/Qodo
P1). The callback must be non-blocking and must not raise; its return
value is ignored.
"""
_kw = {"cancel_token": interrupt} if interrupt is not None else {}
def _snapshot() -> None:
if on_complete is not None:
try:
on_complete(agent)
except Exception:
pass
async def _call_async(fn: Any) -> Any:
try:
return await fn(content, **_kw)
result = await fn(content, **_kw)
except TypeError:
if not _kw:
raise
@@ -3334,7 +3562,11 @@ class WebSocketGateway:
# attribute for this turn (best-effort, non-isolated).
if hasattr(agent, "interrupt_controller"):
agent.interrupt_controller = interrupt
return await fn(content)
result = await fn(content)
# No ``await`` between the turn completing and the snapshot: the
# single-threaded loop cannot run another turn in between.
_snapshot()
return result
for _name in ("arun", "achat"):
_fn = getattr(agent, _name, None)
@@ -3345,13 +3577,18 @@ class WebSocketGateway:
def _call_sync() -> Any:
try:
return agent.chat(content, **_kw)
result = agent.chat(content, **_kw)
except TypeError:
if not _kw:
raise
if hasattr(agent, "interrupt_controller"):
agent.interrupt_controller = interrupt
return agent.chat(content)
result = agent.chat(content)
# Snapshot in *this* worker thread, immediately after the turn
# produced its result, before the thread returns to the pool and a
# concurrent turn can overwrite the agent's shared metrics.
_snapshot()
return result
return await loop.run_in_executor(None, _call_sync)
@@ -5247,12 +5484,58 @@ class WebSocketGateway:
# whether scale-to-zero is armed / the gateway is dormant / an external
# drain watcher is active — without scraping logs. Only included when a
# lifecycle feature is configured, so always-on gateways are unchanged.
lifecycle_block: Dict[str, Any] = {}
if self._idle_policy is not None or self._drain_marker_policy is not None:
result["lifecycle"] = {
lifecycle_block.update({
"scale_to_zero": self._idle_policy is not None,
"dormant": self._is_dormant,
"drain_marker_watch": self._drain_marker_policy is not None,
}
})
# Issue #4603: surface the durable process-lifecycle record — whether the
# *previous* process exited uncleanly (OOM/SIGKILL, which runs no signal
# handler), any suspected OOM, the cross-boot restart count, and whether
# boot-time auto-resume is suspended by the persistent breaker — plus a
# coarse, redaction-safe memory/disk pressure block. Only included when
# the ledger is configured, computed defensively so health() never raises.
ledger = self._lifecycle_ledger
if ledger is not None:
try:
lifecycle_block.update(ledger.last_exit())
except Exception:
pass
guard = self._restart_loop_guard
if guard is not None and hasattr(guard, "_store"):
try:
lifecycle_block["restarts_last_hour"] = len(
[t for t in getattr(guard, "_events", [])
if t >= time.time() - 3600.0]
)
lifecycle_block["auto_resume_suspended"] = bool(
guard.tripped(time.time())
)
except Exception:
pass
try:
pressure = ledger.pressure()
result["pressure"] = pressure
# Mark the surface degraded on *critical* memory/disk so a
# gateway being OOM-killed between kills does not report plain
# healthy. ``unknown`` is surfaced verbatim in the pressure
# block (never asserted healthy) but does not flip status, so a
# host with no cgroup limit to measure keeps today's behaviour.
if self._is_running and any(
pressure.get(k) == "critical" for k in ("memory", "disk")
):
result["status"] = "degraded"
except Exception:
pass
# An unclean prior exit also degrades the status surface.
if lifecycle_block.get("last_exit") == "unclean" and self._is_running:
result["status"] = "degraded"
if lifecycle_block:
result["lifecycle"] = lifecycle_block
# Issue #3410: surface opt-in event-loop watchdog state so an operator
# can confirm the liveness backstop is armed. Only included when
@@ -5326,7 +5609,20 @@ class WebSocketGateway:
try:
pressure = self._compute_pressure()
if pressure is not None:
result["pressure"] = pressure.to_dict()
# Issue #4603: the lifecycle ledger may already have populated
# ``result["pressure"]`` with a coarse ``{"memory", "disk"}``
# block. Merge the saturation/back-pressure fields in rather
# than replacing it, so enabling both features surfaces the OS
# resource pressure *and* the admission/backlog signals on one
# ``pressure`` object instead of one silently clobbering the
# other. Saturation keys ("admission_*", "outbox_*", …) are
# disjoint from the ledger's, so the merge is non-destructive.
existing = result.get("pressure")
merged = pressure.to_dict()
if isinstance(existing, dict):
for key, value in existing.items():
merged.setdefault(key, value)
result["pressure"] = merged
except Exception:
pass
@@ -7250,6 +7546,33 @@ class WebSocketGateway:
elif hasattr(bot, "_identity_resolver"):
bot._identity_resolver = resolver
def _build_turn_lock_map(self) -> Any:
"""Build the gateway's per-turn lock backend from config (Issue #4655).
Honours ``gateway.turn_lock.backend``: the default ``"local"`` returns a
plain in-process ``LockMap`` (today's behaviour, byte-for-byte), while
``"redis"`` returns a ``RedisTurnLock`` reusing the push ``RedisConfig``
so turns serialise cluster-wide across replicas. Construction fails open
(local fallback + a degraded record) so an outage never wedges startup.
"""
cfg = getattr(self, "config", None)
turn_lock = getattr(cfg, "turn_lock", None)
push = getattr(cfg, "push", None)
redis_config = getattr(push, "redis", None)
try:
from ..bots._redis_turn_lock import build_turn_lock
return build_turn_lock(
turn_lock,
redis_config,
degraded_registry=getattr(self, "_degraded_registry", None),
)
except Exception as e: # pragma: no cover - defensive: never block startup
logger.warning("Failed to build turn lock backend: %s", e)
from .._lockmap import LockMap
return LockMap()
def _stamp_turn_lock_map(self, bot: Any) -> None:
"""Share one per-turn ``LockMap`` with a channel bot (Issue #3232).
@@ -7270,12 +7593,19 @@ class WebSocketGateway:
``start_channels`` and ``_start_single_channel`` (hot-reload) so a
restarted channel keeps sharing the same lock map.
"""
if getattr(self, "_identity_resolver", None) is None:
# A local backend only unifies distinct channels onto one session when an
# identity resolver is configured (Issue #3232), so without a resolver it
# stays a no-op and each channel keeps its own map (today's behaviour). A
# distributed (``redis``) backend, however, must serialise across replicas
# of even a *single* channel, so it is wired unconditionally (Issue #4655).
cfg = getattr(self, "config", None)
turn_lock_cfg = getattr(cfg, "turn_lock", None)
distributed = bool(getattr(turn_lock_cfg, "enabled", False))
if getattr(self, "_identity_resolver", None) is None and not distributed:
return
lock_map = getattr(self, "_turn_lock_map", None)
if lock_map is None:
from .._lockmap import LockMap
lock_map = LockMap()
lock_map = self._build_turn_lock_map()
self._turn_lock_map = lock_map
sess = (
getattr(bot, "_session", None)
@@ -1,4 +1,5 @@
"""SQLite kanban store implementation."""
import logging
import sqlite3
import json
import uuid
@@ -13,6 +14,8 @@ from .models import (
)
from .paths import get_kanban_db_path
_logger = logging.getLogger(__name__)
# Board-wide default for the per-task retry/circuit-breaker.
DEFAULT_MAX_RETRIES = 3
@@ -223,11 +226,29 @@ class SQLiteKanbanStore:
if not path or not isinstance(path, str):
return False
try:
import subprocess
result = subprocess.run(
from .._guarded_subprocess import TIMEOUT_RETURNCODE, run_guarded
# Bounded: this sits on the create_task path, which the gateway
# dispatcher calls from its event loop. A git command that stalls
# on a network-mounted or lock-contended path would otherwise
# block every card creation.
result = run_guarded(
["git", "-C", path, "rev-parse", "--is-inside-work-tree"],
capture_output=True, text=True,
timeout=15,
)
if result.returncode == TIMEOUT_RETURNCODE:
# A timeout is "unknown", not "not a repository". We still
# answer False so a slow path can never fail the create, but
# say so: the caller downgrades the task to the shared
# workspace, and silently losing worktree isolation is the
# kind of thing an operator needs to see.
_logger.warning(
"Timed out probing %s for a git worktree; treating it as "
"not a repository, so this task will run in the shared "
"workspace rather than an isolated worktree. %s",
path,
result.stderr.strip(),
)
return result.returncode == 0 and result.stdout.strip() == "true"
except Exception:
return False
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-bot"
version = "0.1.1"
version = "0.1.2"
description = "Bots, gateway, and channel CLI for PraisonAI — messaging platforms and WebSocket control plane extracted from the praisonai wrapper."
readme = "README.md"
license = "MIT"
@@ -323,6 +323,138 @@ class TestAgentMailBotProbe:
assert "not installed" in result.error
class TestAgentMailBotIdempotency:
"""Test AgentMail send/reply pass a stable Idempotency-Key across retries."""
@pytest.mark.asyncio
async def test_same_key_across_retries(self):
"""Every retry of one logical send must reuse the same Idempotency-Key."""
from praisonai_bot.bots import AgentMailBot
bot = AgentMailBot(token="test_token")
bot._inbox_id = "[email protected]"
seen_keys = []
call_count = {"n": 0}
def send_side_effect(*args, **kwargs):
opts = kwargs.get("request_options", {})
seen_keys.append(opts.get("additional_headers", {}).get("Idempotency-Key"))
call_count["n"] += 1
# Fail the first attempt with a recoverable timeout (the exact
# timeout-after-send case) so deliver_outbound() retries.
if call_count["n"] == 1:
raise TimeoutError("simulated timeout after send")
return MagicMock(message_id="sent-123")
mock_client = MagicMock()
mock_client.inboxes.messages.send.side_effect = send_side_effect
with patch.object(bot, "_get_client", return_value=mock_client):
await bot.send_message(
channel_id="[email protected]",
content={"subject": "Hi", "body": "hello"},
)
assert len(seen_keys) >= 2
assert all(k is not None for k in seen_keys)
assert len(set(seen_keys)) == 1
@pytest.mark.asyncio
async def test_reply_passes_idempotency_key(self):
"""messages.reply() must also receive an Idempotency-Key header."""
from praisonai_bot.bots import AgentMailBot
bot = AgentMailBot(token="test_token")
bot._inbox_id = "[email protected]"
mock_client = MagicMock()
mock_client.inboxes.messages.reply.return_value = MagicMock(message_id="r-1")
with patch.object(bot, "_get_client", return_value=mock_client):
await bot.send_message(
channel_id="[email protected]",
content={"subject": "Re: Hi", "body": "reply"},
reply_to="orig-msg-id",
)
_, kwargs = mock_client.inboxes.messages.reply.call_args
key = kwargs["request_options"]["additional_headers"]["Idempotency-Key"]
assert key
@pytest.mark.asyncio
async def test_distinct_sends_get_distinct_keys(self):
"""Two distinct logical sends must receive different keys."""
from praisonai_bot.bots import AgentMailBot
bot = AgentMailBot(token="test_token")
bot._inbox_id = "[email protected]"
keys = []
def capture(*args, **kwargs):
keys.append(
kwargs["request_options"]["additional_headers"]["Idempotency-Key"]
)
return MagicMock(message_id="s")
mock_client = MagicMock()
mock_client.inboxes.messages.send.side_effect = capture
with patch.object(bot, "_get_client", return_value=mock_client):
await bot.send_message(
channel_id="[email protected]", content={"body": "one"}
)
await bot.send_message(
channel_id="[email protected]", content={"body": "two"}
)
assert len(keys) == 2
assert keys[0] != keys[1]
@pytest.mark.asyncio
async def test_explicit_key_is_used(self):
"""A caller-supplied idempotency_key must be forwarded verbatim."""
from praisonai_bot.bots import AgentMailBot
bot = AgentMailBot(token="test_token")
bot._inbox_id = "[email protected]"
keys = []
def capture(*args, **kwargs):
keys.append(
kwargs["request_options"]["additional_headers"]["Idempotency-Key"]
)
return MagicMock(message_id="s")
mock_client = MagicMock()
mock_client.inboxes.messages.send.side_effect = capture
with patch.object(bot, "_get_client", return_value=mock_client):
await bot.send_message(
channel_id="[email protected]",
content={"body": "one"},
idempotency_key="stable-deterministic-key",
)
assert keys == ["stable-deterministic-key"]
def test_idempotency_key_is_explicit_param(self):
"""The param must be declared explicitly so DurableDelivery detects it.
``DurableDelivery._send_accepts_idempotency_key()`` only forwards its
persisted key to adapters whose ``send_message`` signature contains an
explicit ``idempotency_key`` parameter (a ``**kwargs`` fallback is
deliberately excluded). This guards that contract for AgentMail.
"""
import inspect
from praisonai_bot.bots import AgentMailBot
params = inspect.signature(AgentMailBot.send_message).parameters
assert "idempotency_key" in params
class TestAgentMailBotHealth:
"""Test AgentMailBot health functionality."""
@@ -0,0 +1,228 @@
"""Issue #4655 — the distributed turn lock honours ``turn_lock.backend``.
``gateway.turn_lock.backend="redis"`` must actually serialise turns for a
resolved session across replicas, not be silently inert. These tests drive the
wrapper-side ``RedisTurnLock`` / ``build_turn_lock`` with an in-memory fake Redis
(so no server is needed) and verify:
* the factory returns a plain ``LockMap`` for the default ``local`` backend,
* it returns a ``RedisTurnLock`` for ``redis`` and serialises across two
*independent* lock instances sharing one Redis (the cross-replica case),
* a Redis outage fails *open* (local serialisation + a degraded record) rather
than wedging a live turn.
"""
from __future__ import annotations
import asyncio
import time
import pytest
from praisonai_bot._lockmap import LockMap
from praisonai_bot.bots._redis_turn_lock import RedisTurnLock, build_turn_lock
from praisonaiagents.gateway import TurnLockConfig
class FakeRedis:
"""Minimal async Redis supporting SET NX PX, GET, DEL, PEXPIRE and EVAL.
Models per-key TTL expiry (``px``) so a lease that is never renewed becomes
reclaimable, and shared by multiple ``RedisTurnLock`` instances to model
separate replicas talking to one Redis.
"""
def __init__(self) -> None:
self.store: dict = {}
self._expiry: dict = {}
self.eval_calls: list = []
def _expire_if_stale(self, key):
exp = self._expiry.get(key)
if exp is not None and time.monotonic() >= exp:
self.store.pop(key, None)
self._expiry.pop(key, None)
async def set(self, key, value, nx=False, px=None):
self._expire_if_stale(key)
if nx and key in self.store:
return None
self.store[key] = value
if px is not None:
self._expiry[key] = time.monotonic() + (px / 1000.0)
else:
self._expiry.pop(key, None)
return True
async def get(self, key):
self._expire_if_stale(key)
return self.store.get(key)
async def delete(self, key):
self.store.pop(key, None)
self._expiry.pop(key, None)
return 1
async def pexpire(self, key, px):
self._expire_if_stale(key)
if key not in self.store:
return 0
self._expiry[key] = time.monotonic() + (px / 1000.0)
return 1
async def eval(self, script, numkeys, key, *args):
self.eval_calls.append((script, key, args))
self._expire_if_stale(key)
arg = args[0]
if "pexpire" in script:
if self.store.get(key) == arg:
self._expiry[key] = time.monotonic() + (int(args[1]) / 1000.0)
return 1
return 0
# compare-and-del
if self.store.get(key) == arg:
self.store.pop(key, None)
self._expiry.pop(key, None)
return 1
return 0
class BoomRedis:
async def set(self, *a, **k):
raise RuntimeError("redis down")
def test_build_turn_lock_local_returns_lockmap():
lock = build_turn_lock(TurnLockConfig()) # default local
assert isinstance(lock, LockMap)
def test_build_turn_lock_none_returns_lockmap():
assert isinstance(build_turn_lock(None), LockMap)
def test_build_turn_lock_redis_without_config_degrades_to_local():
registry_marks = []
class Reg:
def mark(self, owner):
registry_marks.append(owner)
def clear(self, *a):
pass
lock = build_turn_lock(
TurnLockConfig(backend="redis"), None, degraded_registry=Reg()
)
# No Redis available -> fail open to a local map, and record the degradation.
assert isinstance(lock, LockMap)
assert registry_marks # a degraded entry was recorded
@pytest.mark.asyncio
async def test_redis_turn_lock_serialises_across_instances():
"""Two independent RedisTurnLocks over one Redis serialise a shared key."""
redis = FakeRedis()
a = RedisTurnLock(redis, ttl=5.0, poll_interval=0.01)
b = RedisTurnLock(redis, ttl=5.0, poll_interval=0.01)
order = []
async def worker(lock, label):
async with lock.get("session-1"):
order.append(f"{label}-enter")
await asyncio.sleep(0.05)
order.append(f"{label}-exit")
await asyncio.gather(worker(a, "A"), worker(b, "B"))
# Whoever ran first must fully finish before the other enters.
assert order[0].endswith("enter")
assert order[1].endswith("exit")
first = order[0].split("-")[0]
second = order[2].split("-")[0]
assert first != second
# Lease is released back to Redis after both turns.
assert redis.store == {}
@pytest.mark.asyncio
async def test_redis_turn_lock_release_is_compare_and_del():
redis = FakeRedis()
lock = RedisTurnLock(redis, ttl=5.0, poll_interval=0.01)
async with lock.get("k"):
# While held, the key exists under our namespaced prefix.
assert any("turnlock" in key or key.endswith("k") for key in redis.store)
assert redis.store == {}
@pytest.mark.asyncio
async def test_redis_turn_lock_release_leaves_other_owner_key():
"""Release is owner-checked: a lease reclaimed by another replica survives."""
redis = FakeRedis()
lock = RedisTurnLock(redis, ttl=5.0, poll_interval=0.01)
async with lock.get("k"):
# Simulate another replica reclaiming the expired lease mid-turn.
(redis_key,) = list(redis.store)
redis.store[redis_key] = "other-owner-token"
# Our compare-and-del must NOT delete a key we no longer own.
assert redis.store.get(redis_key) == "other-owner-token"
# And the release went through the atomic EVAL path.
assert redis.eval_calls
@pytest.mark.asyncio
async def test_redis_turn_lock_renews_lease_during_long_turn():
"""A turn longer than ttl keeps the lease via owner-checked renewal."""
redis = FakeRedis()
lock = RedisTurnLock(redis, ttl=0.06, poll_interval=0.01)
async with lock.get("k"):
(redis_key,) = list(redis.store)
first = redis.store[redis_key]
# Run well past ttl; without renewal the key would expire and vanish.
await asyncio.sleep(0.2)
assert redis.store.get(redis_key) == first # still ours
@pytest.mark.asyncio
async def test_redis_turn_lock_reclaims_expired_lease():
"""An unrenewed, expired lease becomes reclaimable (no permanent wedge)."""
redis = FakeRedis()
a = RedisTurnLock(redis, ttl=0.05, poll_interval=0.01)
# Manually plant an expiring lease from a "crashed" holder.
redis_key = a._redis_key("k")
await redis.set(redis_key, "dead-holder", nx=True, px=50)
b = RedisTurnLock(redis, ttl=5.0, poll_interval=0.01)
# b must be able to acquire once the dead holder's lease expires.
async with b.get("k"):
assert redis.store.get(redis_key) != "dead-holder"
@pytest.mark.asyncio
async def test_redis_turn_lock_fails_open_and_serialises_on_outage():
"""A Redis outage must not wedge a turn and must still serialise in-process."""
marks = []
class Reg:
def mark(self, owner):
marks.append(owner)
def clear(self, *a):
pass
lock = RedisTurnLock(BoomRedis(), ttl=5.0, poll_interval=0.01, degraded_registry=Reg())
events = []
async def worker(label):
async with lock.get("k"):
events.append(f"{label}-enter")
await asyncio.sleep(0.03)
events.append(f"{label}-exit")
await asyncio.gather(worker("A"), worker("B"))
# Even with Redis down, the local lock serialises the two turns.
assert events[0].endswith("enter")
assert events[1].endswith("exit")
assert events[0].split("-")[0] != events[2].split("-")[0]
assert marks # degradation surfaced
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Tests for gateway live-session handoff (Issue #4660).
``BotOS.handoff`` re-homes an in-progress conversation onto a freshly created
thread/DM on another platform: it creates the destination thread via the
existing ``DeliveryRouter.create_thread`` primitive, seeds that thread's
session with the origin transcript, optionally posts a seed message and drops a
breadcrumb in the origin — routing subsequent turns to the new channel.
"""
import asyncio
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[5]
sys.path.insert(0, str(REPO_ROOT / "src" / "praisonai"))
sys.path.insert(0, str(REPO_ROOT / "src" / "praisonai-agents"))
from praisonai_bot.bots import BotOS, ThreadRef # noqa: E402
class _FakeSession:
"""Minimal BotSessionManager stand-in for handoff export/seed."""
def __init__(self, histories=None):
self._by_key = dict(histories or {})
self.seeded = {}
def export_history(self, storage_key):
return list(self._by_key.get(storage_key, []))
def seed_history(self, storage_key, history):
self.seeded[storage_key] = list(history)
class _FakeAdapter:
"""Adapter exposing the native thread primitive + capability flag."""
def __init__(self, *, threads=True, thread_id="T-99"):
self.capabilities = {"threads": threads}
self._thread_id = thread_id
self.created = []
async def create_thread(self, channel_id, name):
self.created.append((channel_id, name))
return self._thread_id
class _FakeBot:
"""Channel bot stub: platform + adapter + session manager + home channel."""
def __init__(self, platform, *, adapter, session, home="C-home"):
self.platform = platform
self.adapter = adapter
self._session = session
self._home_channel = home
self.sends = []
async def send_message(self, channel_id, text, thread_id=None):
self.sends.append((channel_id, text, thread_id))
return {"ok": True}
def _make_botos(bot):
botos = BotOS(bots=[])
botos._bots[bot.platform] = bot
# Point the router's home channel so a bare "<platform>" target resolves.
botos.delivery_router.directory.set_home_channel(bot.platform, bot._home_channel)
return botos
def test_handoff_creates_thread_and_seeds_session():
transcript = [
{"role": "user", "content": "start on laptop"},
{"role": "assistant", "content": "working..."},
]
session = _FakeSession({"cli:alice": transcript})
adapter = _FakeAdapter(threads=True, thread_id="T-42")
bot = _FakeBot("telegram", adapter=adapter, session=session)
botos = _make_botos(bot)
ref = asyncio.run(botos.handoff("cli:alice", "telegram"))
assert isinstance(ref, ThreadRef)
assert ref.ok
assert ref.platform == "telegram"
assert ref.thread_id == "T-42"
assert ref.session_key == "telegram:C-home:T-42"
assert ref.target == "telegram:C-home:T-42"
# Thread was created and the origin transcript was seeded onto it.
assert adapter.created == [("C-home", "Handoff")]
assert session.seeded["telegram:C-home:T-42"] == transcript
def test_handoff_posts_seed_text_into_new_thread():
session = _FakeSession({"cli:alice": [{"role": "user", "content": "hi"}]})
adapter = _FakeAdapter(thread_id="T-7")
bot = _FakeBot("telegram", adapter=adapter, session=session)
botos = _make_botos(bot)
ref = asyncio.run(
botos.handoff("cli:alice", "telegram", seed_text="Continuing here")
)
assert ref.ok
# The seed message was delivered to the new thread.
assert any(
text == "Continuing here" and thread == "T-7"
for (_chan, text, thread) in bot.sends
)
def test_handoff_unsupported_when_adapter_cannot_thread():
session = _FakeSession({"cli:alice": [{"role": "user", "content": "hi"}]})
adapter = _FakeAdapter(threads=False)
bot = _FakeBot("telegram", adapter=adapter, session=session)
botos = _make_botos(bot)
ref = asyncio.run(botos.handoff("cli:alice", "telegram"))
assert not ref.ok
assert ref.status == "unsupported"
# No session was seeded for a handoff that could not create a thread.
assert session.seeded == {}
def test_handoff_no_route_for_unknown_platform():
session = _FakeSession({"cli:alice": [{"role": "user", "content": "hi"}]})
adapter = _FakeAdapter()
bot = _FakeBot("telegram", adapter=adapter, session=session)
botos = _make_botos(bot)
ref = asyncio.run(botos.handoff("cli:alice", "whatsapp"))
assert not ref.ok
assert ref.status in ("no_route", "unsupported")
assert session.seeded == {}
def test_handoff_of_empty_session_still_creates_thread():
session = _FakeSession({}) # no transcript for this key
adapter = _FakeAdapter(thread_id="T-1")
bot = _FakeBot("telegram", adapter=adapter, session=session)
botos = _make_botos(bot)
ref = asyncio.run(botos.handoff("cli:ghost", "telegram"))
assert ref.ok
assert adapter.created == [("C-home", "Handoff")]
# Nothing to seed, but the thread was still opened cleanly.
assert session.seeded == {}
if __name__ == "__main__":
test_handoff_creates_thread_and_seeds_session()
test_handoff_posts_seed_text_into_new_thread()
test_handoff_unsupported_when_adapter_cannot_thread()
test_handoff_no_route_for_unknown_platform()
test_handoff_of_empty_session_still_creates_thread()
print("all handoff tests passed")
@@ -156,3 +156,100 @@ class TestSessionKeyDerivation:
key = mgr._session_key("12345")
assert "alice-global" in key
assert "telegram" not in key # unified, not platform-prefixed
class TestIdentityCanonicalization:
"""Issue #4661: a volatile platform id is canonicalised before it keys
the session (via an ``IdentityCanonicalizerProtocol``), so an alias/format
change for the same human collapses to one session instead of silently
forking."""
@staticmethod
def _canon(mapping):
class _Canon:
def canonicalize(self, platform, raw_user_id):
return mapping.get(raw_user_id, raw_user_id)
return _Canon()
def test_no_canonicalizer_is_identity_default(self):
# Default: raw id keys the session, byte-for-byte unchanged.
mgr = BotSessionManager(platform="whatsapp")
assert mgr._storage_key("<JID>") == "<JID>"
assert mgr._canonical_user_id("<JID>") == "<JID>"
def test_canonicalizer_collapses_alias_before_key(self):
# Simulate WhatsApp JID/LID for one person collapsing to a stable id.
canon = self._canon({"jid-A": "person-1", "lid-B": "person-1"})
mgr = BotSessionManager(platform="whatsapp", identity_canonicalizer=canon)
assert mgr._storage_key("jid-A") == mgr._storage_key("lid-B")
assert mgr._storage_key("jid-A") == "person-1"
def test_canonicalizer_runs_before_resolver(self):
from praisonaiagents.session.identity import InMemoryIdentityResolver
# Resolver only knows the canonical id, not the raw volatile ones.
resolver = InMemoryIdentityResolver()
resolver.link("whatsapp", "person-1", "alice-global")
canon = self._canon({"jid-A": "person-1", "lid-B": "person-1"})
mgr = BotSessionManager(
platform="whatsapp",
identity_resolver=resolver,
identity_canonicalizer=canon,
)
# Both raw forms canonicalise to person-1, which the resolver unifies.
assert mgr._storage_key("jid-A") == "alice-global"
assert mgr._storage_key("lid-B") == "alice-global"
def test_canonicalizer_failure_falls_back_to_raw(self):
class Boom:
def canonicalize(self, platform, raw_user_id):
raise RuntimeError("canonicalizer bug")
mgr = BotSessionManager(platform="whatsapp", identity_canonicalizer=Boom())
# Fail-open: a buggy canonicalizer never breaks session keying.
assert mgr._storage_key("jid-A") == "jid-A"
def test_empty_result_falls_back_to_raw(self):
mgr = BotSessionManager(
platform="whatsapp", identity_canonicalizer=self._canon({"jid-A": ""})
)
assert mgr._storage_key("jid-A") == "jid-A"
def test_base_adapter_is_a_canonicalizer_and_identity_by_default(self):
from praisonaiagents.bots import BasePlatformAdapter
from praisonaiagents.gateway.protocols import IdentityCanonicalizerProtocol
class MinimalAdapter(BasePlatformAdapter):
async def connect(self, *, is_reconnect=False):
return True
async def disconnect(self):
return None
async def send(self, chat_id, content, *, reply_to=None, metadata=None):
from praisonaiagents.bots import SendResult
return SendResult(ok=True, chat_id=chat_id)
async def get_chat_info(self, chat_id):
return {"id": chat_id}
adapter = MinimalAdapter()
# The adapter structurally *is* an IdentityCanonicalizerProtocol.
assert isinstance(adapter, IdentityCanonicalizerProtocol)
assert adapter.canonicalize("whatsapp", "raw-id") == "raw-id"
# Wired as the session's canonicalizer, it is a no-op by default.
mgr = BotSessionManager(platform="whatsapp", identity_canonicalizer=adapter)
assert mgr._storage_key("raw-id") == "raw-id"
def test_attach_gateway_runtime_adopts_canonicalizer(self):
from praisonaiagents.bots import GatewayRuntimeSeams
canon = self._canon({"jid-A": "person-1"})
mgr = BotSessionManager(platform="whatsapp")
mgr.attach_gateway_runtime(
GatewayRuntimeSeams(identity_canonicalizer=canon)
)
assert mgr._storage_key("jid-A") == "person-1"
@@ -0,0 +1,201 @@
"""Tests for turn-liveness binding of gateway approvals (issue #4598).
Verifies that a pending approval bound to a session/run-generation is:
- cancelled (fail-closed) when its turn is superseded/stopped, unblocking the
awaiting caller promptly (so ``/stop`` produces a visible outcome); and
- dropped at the resolution boundary if a resolution arrives after the turn
was superseded (no stale tool execution / reply); while
- unbound requests (no session/generation) keep today's behaviour.
"""
import asyncio
from praisonai_bot.gateway.exec_approval import (
ExecApprovalManager,
Resolution,
)
def test_unbound_request_is_always_live(tmp_path):
"""A request with no session/generation resolves normally (unchanged)."""
mgr = ExecApprovalManager(ttl=300, allowlist_path=tmp_path / "allow.sqlite")
async def go():
rid, future = await mgr.register(
tool_name="shell_exec", arguments={"cmd": "ls"}, agent_name="a"
)
assert mgr.resolve(rid, Resolution(approved=True)) is True
return await future
res = asyncio.run(go())
assert res.approved is True
def test_cancel_for_generation_unblocks_pending(tmp_path):
"""A /stop-style cancel completes the pending future as denied promptly."""
mgr = ExecApprovalManager(ttl=300, allowlist_path=tmp_path / "allow.sqlite")
async def go():
rid, future = await mgr.register(
tool_name="shell_exec",
arguments={"cmd": "rm -rf x"},
agent_name="a",
session_id="s1",
run_generation=1,
)
n = mgr.cancel_for_generation("s1", 1)
assert n == 1
return rid, await future
rid, res = asyncio.run(go())
assert res.approved is False
assert res.reason == "cancelled"
# The request is gone; a later resolve is a no-op.
assert mgr.resolve(rid, Resolution(approved=True)) is False
def test_stale_resolution_is_dropped(tmp_path):
"""A resolution arriving after supersede is dropped (fail-closed)."""
mgr = ExecApprovalManager(ttl=300, allowlist_path=tmp_path / "allow.sqlite")
async def go():
_rid, _future = await mgr.register(
tool_name="shell_exec",
arguments={"cmd": "deploy"},
agent_name="a",
session_id="s1",
run_generation=1,
)
# Turn superseded by a newer generation, but this old pending was not
# in the cancel batch (e.g. registered independently); mark superseded.
mgr.cancel_for_generation("s1", 1)
# Re-register a fresh pending for the SAME old generation to simulate a
# late resolution hitting a stale turn. With fail-closed registration
# this future is denied at register() time; either way the reviewer's
# late approval must be dropped and the future resolves "superseded".
rid2, future2 = await mgr.register(
tool_name="shell_exec",
arguments={"cmd": "deploy"},
agent_name="a",
session_id="s1",
run_generation=1,
)
# Reviewer approves the stale turn -> must be dropped.
assert mgr.resolve(rid2, Resolution(approved=True)) is False
return await future2
res = asyncio.run(go())
assert res.approved is False
assert res.reason == "superseded"
def test_newer_generation_still_live(tmp_path):
"""Superseding an old generation does not affect a newer one."""
mgr = ExecApprovalManager(ttl=300, allowlist_path=tmp_path / "allow.sqlite")
async def go():
mgr.cancel_for_generation("s1", 1) # supersede gen 1
rid, future = await mgr.register(
tool_name="shell_exec",
arguments={"cmd": "ls"},
agent_name="a",
session_id="s1",
run_generation=2, # newer, still live
)
assert mgr.resolve(rid, Resolution(approved=True)) is True
return await future
res = asyncio.run(go())
assert res.approved is True
def test_register_after_supersede_is_denied_immediately(tmp_path):
"""Registering for an already-superseded generation fail-closes at once.
Guards the TOCTOU window: if /stop marks the generation before the tool's
register() call lands, the caller must not park on a future no cancel batch
will ever complete.
"""
mgr = ExecApprovalManager(ttl=300, allowlist_path=tmp_path / "allow.sqlite")
async def go():
mgr.cancel_for_generation("s1", 5) # supersede up to gen 5
rid, future = await mgr.register(
tool_name="shell_exec",
arguments={"cmd": "rm -rf x"},
agent_name="a",
session_id="s1",
run_generation=5, # <= superseded -> denied at registration
)
# Not inserted: a resolve finds nothing.
assert mgr.resolve(rid, Resolution(approved=True)) is False
assert future.done()
return await future
res = asyncio.run(go())
assert res.approved is False
assert res.reason == "superseded"
def test_non_integer_run_generation_registers_unbound(tmp_path):
"""A non-integer run_generation must not break cancellation.
An un-coercible generation drops the (untrustworthy) binding to unbound so
the request still resolves normally instead of silently un-cancellable.
"""
mgr = ExecApprovalManager(ttl=300, allowlist_path=tmp_path / "allow.sqlite")
async def go():
rid, future = await mgr.register(
tool_name="shell_exec",
arguments={"cmd": "ls"},
agent_name="a",
session_id="s1",
run_generation="not-an-int", # type: ignore[arg-type]
)
# Unbound -> a matching cancel does not touch it and resolve works.
assert mgr.cancel_for_generation("s1", 1) == 0
assert mgr.resolve(rid, Resolution(approved=True)) is True
return await future
res = asyncio.run(go())
assert res.approved is True
def test_supersede_callback_wires_stop_to_cancel(tmp_path):
"""End-to-end: SessionRunControl.stop() cancels the turn's pending approval.
Exercises the production wiring (``make_approval_supersede_callback`` +
``on_supersede``) rather than calling the manager in isolation, so /stop
genuinely unblocks an approval-parked turn.
"""
from praisonai_bot.bots._run_control import (
SessionRunControl,
make_approval_supersede_callback,
)
mgr = ExecApprovalManager(ttl=300, allowlist_path=tmp_path / "allow.sqlite")
run_control = SessionRunControl(
busy_mode="interrupt",
on_supersede=make_approval_supersede_callback(mgr),
)
async def go():
# Start a run so the session has generation 1, then park an approval
# bound to (user, gen) exactly as the gateway would.
assert (await run_control.submit("user1", "deploy")).value == "run_now"
gen = run_control.get_run_status("user1")["run_generation"]
_rid, future = await mgr.register(
tool_name="shell_exec",
arguments={"cmd": "deploy"},
agent_name="a",
session_id="user1",
run_generation=gen,
)
# /stop must fire on_supersede -> cancel_for_generation -> unblock.
assert await run_control.stop("user1") is True
return await future
res = asyncio.run(go())
assert res.approved is False
assert res.reason == "cancelled"
@@ -19,7 +19,19 @@ from praisonaiagents.gateway import GatewayConfig, ApiConfig
from praisonai_bot.gateway.api_endpoints import GatewayApiEndpoints, _extract_text
class _FakeMetrics:
input_tokens = 11
output_tokens = 7
class _FakeLLM:
last_token_metrics = _FakeMetrics()
class _FakeAgent:
def __init__(self):
self._llm_instance = _FakeLLM()
async def achat(self, content):
return f"echo:{content}"
@@ -47,8 +59,13 @@ class _FakeGateway:
return _FakeSession()
@staticmethod
async def _dispatch_agent_turn(agent, content):
return await agent.achat(content)
async def _dispatch_agent_turn(agent, content, on_complete=None):
result = await agent.achat(content)
# Mirror the real gateway: snapshot per-turn state in the same context
# that produced the result, before returning to the caller.
if on_complete is not None:
on_complete(agent)
return result
class _FakeReq:
@@ -132,6 +149,260 @@ def test_openai_chat_dispatches_to_registered_agent():
assert data["choices"][0]["message"]["content"] == "echo:hi"
def test_openai_chat_reports_real_usage():
ep = GatewayApiEndpoints(_FakeGateway())
resp = asyncio.run(
ep.openai_chat(
_FakeReq(
{"model": "assistant", "messages": [{"role": "user", "content": "hi"}]}
)
)
)
usage = _body(resp)["usage"]
assert usage["prompt_tokens"] == 11
assert usage["completion_tokens"] == 7
assert usage["total_tokens"] == 18
def test_openai_chat_usage_zero_when_no_metrics():
class _NoMetricsAgent:
async def achat(self, content):
return f"echo:{content}"
class _Gw(_FakeGateway):
def __init__(self):
super().__init__()
self._agent = _NoMetricsAgent()
ep = GatewayApiEndpoints(_Gw())
resp = asyncio.run(
ep.openai_chat(
_FakeReq(
{"model": "assistant", "messages": [{"role": "user", "content": "hi"}]}
)
)
)
usage = _body(resp)["usage"]
assert usage == {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
def _collect_sse(resp):
async def _run():
chunks = []
async for part in resp.body_iterator:
chunks.append(part if isinstance(part, str) else part.decode())
return chunks
return asyncio.run(_run())
def test_openai_chat_stream_emits_usage_chunk_when_opted_in():
ep = GatewayApiEndpoints(_FakeGateway())
resp = asyncio.run(
ep.openai_chat(
_FakeReq(
{
"model": "assistant",
"stream": True,
"stream_options": {"include_usage": True},
"messages": [{"role": "user", "content": "hi"}],
}
)
)
)
parts = _collect_sse(resp)
usage_payloads = [
json.loads(p[len("data: "):])
for p in parts
if p.startswith("data: ") and '"usage"' in p
]
assert usage_payloads, "expected a usage-bearing chunk"
usage = usage_payloads[-1]["usage"]
assert usage["prompt_tokens"] == 11
assert usage["completion_tokens"] == 7
assert usage["total_tokens"] == 18
assert parts[-1] == "data: [DONE]\n\n"
def test_openai_chat_stream_omits_usage_by_default():
ep = GatewayApiEndpoints(_FakeGateway())
resp = asyncio.run(
ep.openai_chat(
_FakeReq(
{
"model": "assistant",
"stream": True,
"messages": [{"role": "user", "content": "hi"}],
}
)
)
)
parts = _collect_sse(resp)
assert not any('"usage"' in p for p in parts)
assert parts[-1] == "data: [DONE]\n\n"
def test_dispatch_binds_usage_snapshot_to_its_turn():
# ``_dispatch`` must return the token usage snapshotted atomically with the
# turn result. A later overwrite of the shared, mutable ``last_token_metrics``
# (as a concurrent turn on the same Agent would do) must not change the
# already-returned snapshot.
class _MutableMetrics:
def __init__(self, i, o):
self.input_tokens = i
self.output_tokens = o
class _SharedLLM:
def __init__(self):
self.last_token_metrics = _MutableMetrics(11, 7)
class _SharedAgent:
def __init__(self):
self._llm_instance = _SharedLLM()
async def achat(self, content):
return f"echo:{content}"
class _Gw(_FakeGateway):
def __init__(self):
super().__init__()
self._agent = _SharedAgent()
gw = _Gw()
ep = GatewayApiEndpoints(gw)
reply, usage = asyncio.run(
ep._dispatch(_FakeSession(), gw._agent, "hi")
)
# Mutate the shared metric AFTER dispatch returned its snapshot.
gw._agent._llm_instance.last_token_metrics = _MutableMetrics(999, 999)
assert reply == "echo:hi"
assert usage == {"prompt_tokens": 11, "completion_tokens": 7, "total_tokens": 18}
def test_stream_usage_snapshot_survives_concurrent_overwrite():
# The SSE generator yields (suspends) several frames after the turn before
# emitting the usage chunk. While suspended, a concurrent turn on the same
# shared Agent can overwrite ``last_token_metrics``. The streamed usage must
# still reflect THIS turn (snapshotted at dispatch), not the overwrite.
class _MutableMetrics:
def __init__(self, i, o):
self.input_tokens = i
self.output_tokens = o
class _SharedLLM:
def __init__(self):
self.last_token_metrics = _MutableMetrics(11, 7)
class _SharedAgent:
def __init__(self):
self._llm_instance = _SharedLLM()
async def achat(self, content):
return f"echo:{content}"
class _Gw(_FakeGateway):
def __init__(self):
super().__init__()
self._agent = _SharedAgent()
gw = _Gw()
ep = GatewayApiEndpoints(gw)
resp = asyncio.run(
ep.openai_chat(
_FakeReq(
{
"model": "assistant",
"stream": True,
"stream_options": {"include_usage": True},
"messages": [{"role": "user", "content": "hi"}],
}
)
)
)
async def _drain_with_overwrite():
parts = []
async for part in resp.body_iterator:
text = part if isinstance(part, str) else part.decode()
parts.append(text)
# Once the assistant content frame has streamed (i.e. the turn has
# already completed and been snapshotted), simulate a *concurrent*
# turn overwriting the shared, mutable metric before the usage frame.
if '"content"' in text:
gw._agent._llm_instance.last_token_metrics = _MutableMetrics(
999, 999
)
return parts
parts = asyncio.run(_drain_with_overwrite())
usage_payloads = [
json.loads(p[len("data: "):])
for p in parts
if p.startswith("data: ") and '"usage"' in p
]
assert usage_payloads, "expected a usage-bearing chunk"
usage = usage_payloads[-1]["usage"]
# Snapshotted at dispatch -> original counts, not the mid-stream overwrite.
assert usage == {"prompt_tokens": 11, "completion_tokens": 7, "total_tokens": 18}
def test_sync_agent_usage_snapshot_captured_before_thread_returns():
# Greptile/Qodo P1 (sync fallback): a sync-only agent's ``chat`` runs in a
# worker THREAD. A bare read AFTER the executor ``await`` resolves is NOT
# atomic with the turn — the "no intervening await" guarantee only orders
# event-loop coroutines, not worker threads — so a concurrent turn can
# overwrite the shared, mutable ``last_token_metrics`` in the window between
# the turn's ``chat`` returning and the handler reading it. The real
# gateway's ``_dispatch_agent_turn`` must therefore snapshot INSIDE the
# worker thread, before it returns, via ``on_complete``. We assert exactly
# that ordering against the real server method.
from praisonai_bot.gateway.server import WebSocketGateway
events: list = []
class _Metrics:
input_tokens = 11
output_tokens = 7
class _LLM:
last_token_metrics = _Metrics()
class _SyncAgent:
# No ``arun``/``achat`` -> forces the sync ``chat`` executor path.
_llm_instance = _LLM()
def chat(self, content):
events.append("chat_returned")
return f"echo:{content}"
def _capture(a):
# Records that the snapshot ran, and its ordering vs ``chat`` return.
events.append("snapshot")
reply = asyncio.run(
WebSocketGateway._dispatch_agent_turn(
_SyncAgent(), "hi", on_complete=_capture
)
)
assert reply == "echo:hi"
# The snapshot must fire in the SAME worker-thread context, immediately
# after ``chat`` returns and before the future resolves back on the loop —
# so ``chat_returned`` is immediately followed by ``snapshot`` with no gap
# a concurrent turn could exploit.
assert events == ["chat_returned", "snapshot"]
def test_openai_responses_reports_usage():
ep = GatewayApiEndpoints(_FakeGateway())
resp = asyncio.run(
ep.openai_responses(_FakeReq({"model": "assistant", "input": "ping"}))
)
usage = _body(resp)["usage"]
assert usage["input_tokens"] == 11
assert usage["output_tokens"] == 7
assert usage["total_tokens"] == 18
def test_openai_chat_no_agents_returns_503():
class _Empty(_FakeGateway):
def list_agents(self):
@@ -222,6 +222,27 @@ def test_turn_lock_stamp_is_noop_without_resolver():
assert getattr(gateway, "_turn_lock_map", None) is None
def test_turn_lock_stamp_wires_redis_backend_without_resolver():
"""Issue #4655: ``turn_lock.backend='redis'`` is wired even with no resolver.
Previously the gateway built a process-local ``LockMap`` and only stamped it
when a resolver was configured, so ``backend='redis'`` was silently inert and
replicas of a single channel ran concurrent turns on one session.
"""
from praisonaiagents.gateway import TurnLockConfig
gateway = _gateway_with_agent()
assert gateway._identity_resolver is None
# Force a redis backend but with no reachable Redis -> build_turn_lock fails
# open to a LockMap while recording a degraded entry; the important part is
# that the map IS stamped onto the session (the wiring path is reached).
gateway.config.turn_lock = TurnLockConfig(backend="redis")
bot = _FakeBot()
gateway._stamp_turn_lock_map(bot)
assert getattr(gateway, "_turn_lock_map", None) is not None
assert bot._session._locks is gateway._turn_lock_map
def test_turn_lock_stamp_shares_one_map_with_session():
"""A configured resolver stamps the gateway's shared LockMap onto the session."""
resolver = _StubResolver()
@@ -0,0 +1,192 @@
"""Tests for the durable gateway lifecycle ledger wiring (Issue #4603).
The core primitives (``LifecycleRecord`` / ``classify_unclean_exit`` /
``PersistentRestartLoopGuard`` / ``classify_resource_pressure``) are unit-tested
in the core SDK; here we verify the *wrapper mechanism* — the durable sentinel,
the boot classification, the graceful-exit marker, the memory sample, and the
sentinel-backed restart store.
"""
from __future__ import annotations
import asyncio
import json
import os
import pytest
from praisonai_bot.gateway.lifecycle_ledger import (
LifecycleLedger,
SentinelRestartStore,
build_persistent_restart_guard,
default_sentinel_path,
)
def test_fresh_boot_is_unknown(tmp_path):
ledger = LifecycleLedger(str(tmp_path / "lifecycle.json"))
rec = ledger.record_boot()
assert rec.exit_kind == "unknown"
assert ledger.last_exit() == {"last_exit": "unknown", "suspected_oom": False}
def test_graceful_stop_marks_clean_and_next_boot_sees_clean(tmp_path):
path = str(tmp_path / "lifecycle.json")
ledger = LifecycleLedger(path)
ledger.record_boot()
ledger.mark_exited()
data = json.loads((tmp_path / "lifecycle.json").read_text())
assert data["phase"] == "exited"
assert data["exit_kind"] == "clean"
ledger2 = LifecycleLedger(path)
rec = ledger2.record_boot()
assert rec.exit_kind == "clean"
def test_unclean_death_detected_on_next_boot(tmp_path):
path = str(tmp_path / "lifecycle.json")
# Process A boots and never marks exited (simulated OOM/SIGKILL).
ledger_a = LifecycleLedger(path, memory_budget_fraction=0.9)
ledger_a.record_boot()
# A high memory sample was folded in by the heartbeat before death.
from praisonaiagents.gateway import LifecycleRecord
ledger_a._record = LifecycleRecord(
phase="running", exit_kind="unknown", last_mem_fraction=0.97,
)
ledger_a._write(ledger_a._record)
# Process B boots and detects the unclean prior exit.
ledger_b = LifecycleLedger(path, memory_budget_fraction=0.9)
rec = ledger_b.record_boot()
assert rec.exit_kind == "unclean"
assert rec.suspected_oom is True
assert ledger_b.last_exit() == {"last_exit": "unclean", "suspected_oom": True}
def test_mark_exited_is_ownership_checked(tmp_path, monkeypatch):
path = str(tmp_path / "lifecycle.json")
ledger = LifecycleLedger(path)
ledger.record_boot()
# Pretend a different pid owns the boot marker: mark_exited must be a no-op.
ledger._owner_pid = os.getpid() + 1
ledger.mark_exited()
data = json.loads((tmp_path / "lifecycle.json").read_text())
assert data["phase"] == "running"
def test_sample_memory_never_raises(tmp_path):
ledger = LifecycleLedger(str(tmp_path / "lifecycle.json"))
ledger.record_boot()
# Returns a float fraction or None; must not raise on any host.
frac = ledger.sample_memory()
assert frac is None or isinstance(frac, float)
def test_pressure_returns_closed_vocabulary(tmp_path):
ledger = LifecycleLedger(str(tmp_path / "lifecycle.json"))
p = ledger.pressure()
assert set(p.keys()) == {"memory", "disk"}
for v in p.values():
assert v in ("ok", "elevated", "critical", "unknown")
def test_pressure_disk_ok_on_tmp(tmp_path):
# A tmp dir on CI has ample headroom; disk should classify ok (not unknown).
ledger = LifecycleLedger(str(tmp_path / "lifecycle.json"), disk_min_free_mb=1)
assert ledger.pressure()["disk"] == "ok"
def test_restart_store_shares_sentinel(tmp_path):
path = str(tmp_path / "lifecycle.json")
ledger = LifecycleLedger(path)
ledger.record_boot()
guard = build_persistent_restart_guard(ledger, max_restarts=3, window_seconds=1e12)
assert guard.record(now=1.0) is False
assert guard.record(now=2.0) is False
assert guard.record(now=3.0) is True
# The events landed in the shared sentinel and reload in a fresh process.
ledger2 = LifecycleLedger(path)
ledger2.record_boot()
guard2 = build_persistent_restart_guard(ledger2, max_restarts=3, window_seconds=1e12)
assert guard2.tripped(now=4.0) is True
def test_default_sentinel_path_under_praisonai():
p = default_sentinel_path()
assert p.endswith(os.path.join(".praisonai", "gateway", "lifecycle.json"))
def test_corrupt_sentinel_is_tolerated(tmp_path):
path = tmp_path / "lifecycle.json"
path.write_text("{not valid json")
ledger = LifecycleLedger(str(path))
# A corrupt prior file reads as "no record" -> unknown, never raises.
rec = ledger.record_boot()
assert rec.exit_kind == "unknown"
# ── server integration: the two P1s (graceful clean-exit + pressure merge) ──
def _make_gateway_with_ledger(tmp_path):
"""A bare WebSocketGateway with only a lifecycle ledger wired (no server)."""
from praisonai_bot.gateway import WebSocketGateway
gw = WebSocketGateway()
gw._lifecycle_ledger = LifecycleLedger(str(tmp_path / "lifecycle.json"))
return gw
def test_graceful_serve_teardown_marks_clean_exit(tmp_path):
"""Issue #4603 P1: ``serve()`` returning (the SIGTERM path, which only flips
``should_exit`` and never calls ``stop()``) must still stamp a clean exit so
the next boot does not misreport a graceful shutdown as unclean/OOM."""
path = str(tmp_path / "lifecycle.json")
gw = _make_gateway_with_ledger(tmp_path)
gw._lifecycle_ledger.path = path
gw._lifecycle_ledger.record_boot()
# Simulate ``serve()``'s finally block running on graceful shutdown.
asyncio.run(gw._cancel_ledger_heartbeat())
gw._lifecycle_ledger.mark_exited()
data = json.loads((tmp_path / "lifecycle.json").read_text())
assert data["phase"] == "exited"
assert data["exit_kind"] == "clean"
# A fresh process now sees a *clean* prior exit, not an unclean one.
ledger2 = LifecycleLedger(path)
assert ledger2.record_boot().exit_kind == "clean"
def test_health_merges_ledger_and_saturation_pressure(tmp_path):
"""Issue #4603 P1: when both the lifecycle ledger and the saturation
back-pressure machinery are enabled, ``/health`` must surface *both* the
memory/disk block and the admission/backlog block on one ``pressure``
object — neither may silently clobber the other."""
from praisonaiagents.gateway import HealthPressure
gw = _make_gateway_with_ledger(tmp_path)
gw._is_running = True
# Force a known ledger pressure block and a known saturation snapshot.
gw._lifecycle_ledger.pressure = lambda: {"memory": "ok", "disk": "ok"}
gw._lifecycle_ledger.last_exit = lambda: {
"last_exit": "clean", "suspected_oom": False,
}
gw._compute_pressure = lambda: HealthPressure(
admission_in_flight=2, outbox_pending=5, pressure="elevated"
)
result = gw.health()
pressure = result["pressure"]
# Ledger's OS resource keys survive...
assert pressure["memory"] == "ok"
assert pressure["disk"] == "ok"
# ...alongside the saturation keys.
assert pressure["admission_in_flight"] == 2
assert pressure["outbox_pending"] == 5
assert pressure["pressure"] == "elevated"
@@ -0,0 +1,331 @@
"""Tests for the bounded subprocess wrapper.
The behaviour that matters: a command that would block forever returns, the
caller can still branch on returncode, and the stderr it gets back says *why*
the command stalled.
"""
import os
import subprocess
import sys
import pytest
from praisonai_bot._guarded_subprocess import (
DEFAULT_TIMEOUT,
TIMEOUT_RETURNCODE,
diagnose,
run_guarded,
)
class TestHappyPath:
"""A bounded call must stay a faithful subprocess.run replacement."""
def test_returns_completed_process(self):
result = run_guarded([sys.executable, "-c", "print('ok')"])
assert result.returncode == 0
assert result.stdout.strip() == "ok"
def test_propagates_nonzero_exit(self):
result = run_guarded([sys.executable, "-c", "import sys; sys.exit(3)"])
assert result.returncode == 3
def test_honours_cwd(self, tmp_path):
result = run_guarded(
[sys.executable, "-c", "import os; print(os.getcwd())"],
cwd=str(tmp_path),
)
assert os.path.realpath(result.stdout.strip()) == os.path.realpath(str(tmp_path))
class TestTimeout:
"""A stalled command returns a value; it never raises and never blocks."""
def test_hanging_command_returns_instead_of_blocking(self):
result = run_guarded(
[sys.executable, "-c", "import time; time.sleep(30)"],
timeout=0.5,
)
assert result.returncode == TIMEOUT_RETURNCODE
def test_timeout_does_not_raise(self):
# The whole point: existing `returncode == 0` call sites keep working
# without growing an exception handler.
run_guarded([sys.executable, "-c", "import time; time.sleep(30)"], timeout=0.3)
def test_stderr_names_the_command_and_the_limit(self):
result = run_guarded(
[sys.executable, "-c", "import time; time.sleep(30)"],
timeout=0.4,
)
assert "killed after 0.4s" in result.stderr
assert "time.sleep(30)" in result.stderr
def test_reading_stdin_is_not_a_hang(self):
# stdin is /dev/null, so a command that reads it gets EOF immediately
# rather than waiting for the timeout to save it.
result = run_guarded(
[sys.executable, "-c", "import sys; sys.stdin.read(); print('done')"],
timeout=5,
)
assert result.returncode == 0
assert "done" in result.stdout
class TestDiagnosis:
"""Stall output is turned into guidance a caller can act on."""
def test_credential_prompt(self):
assert "credential prompt" in diagnose("Username for 'https://github.com':")
def test_git_lock(self):
msg = diagnose("fatal: Unable to create '/repo/.git/index.lock': File exists")
assert "lock contention" in msg
def test_confirmation_prompt(self):
assert "confirmation prompt" in diagnose("Overwrite existing file? [y/N]")
def test_host_key_prompt(self):
assert "host-key" in diagnose("The authenticity of host ... key fingerprint is")
def test_unknown_output_gets_generic_guidance(self):
msg = diagnose("compiling module 41 of 900")
assert "No interactive prompt was detected" in msg
def test_empty_output_is_safe(self):
# A process blocked on stdin often emits nothing at all.
assert diagnose("") == diagnose(None)
def test_diagnosis_is_attached_on_timeout(self):
script = (
"import sys, time; "
"sys.stderr.write(\"Username for 'https://github.com':\"); "
"sys.stderr.flush(); time.sleep(30)"
)
result = run_guarded([sys.executable, "-c", script], timeout=1.0)
assert result.returncode == TIMEOUT_RETURNCODE
assert "credential prompt" in result.stderr
class TestEnvironment:
"""Prompt suppression is layered under the caller, never over it."""
def test_suppresses_git_prompting_by_default(self):
result = run_guarded(
[sys.executable, "-c", "import os; print(os.environ['GIT_TERMINAL_PROMPT'])"]
)
assert result.stdout.strip() == "0"
def test_caller_env_wins(self):
result = run_guarded(
[sys.executable, "-c", "import os; print(os.environ['GIT_TERMINAL_PROMPT'])"],
env={"GIT_TERMINAL_PROMPT": "1", "PATH": os.environ.get("PATH", "")},
)
assert result.stdout.strip() == "1"
def test_interactive_mode_adds_no_suppression(self, monkeypatch):
# Clear the ambient value first -- CI images commonly export
# GIT_TERMINAL_PROMPT=0 already, which would mask the behaviour under
# test. Interactive mode must not *introduce* the variable itself.
monkeypatch.delenv("GIT_TERMINAL_PROMPT", raising=False)
result = run_guarded(
[
sys.executable,
"-c",
"import os; print(os.environ.get('GIT_TERMINAL_PROMPT', 'unset'))",
],
interactive=True,
)
assert result.stdout.strip() == "unset"
def test_default_mode_suppresses_even_when_ambient_is_unset(self, monkeypatch):
monkeypatch.delenv("GIT_TERMINAL_PROMPT", raising=False)
result = run_guarded(
[sys.executable, "-c", "import os; print(os.environ['GIT_TERMINAL_PROMPT'])"]
)
assert result.stdout.strip() == "0"
class TestTimeoutResolution:
"""Explicit argument beats env var beats default; junk never wins."""
def test_env_override(self, monkeypatch):
monkeypatch.setenv("PRAISONAI_SUBPROCESS_TIMEOUT", "0.3")
result = run_guarded([sys.executable, "-c", "import time; time.sleep(30)"])
assert result.returncode == TIMEOUT_RETURNCODE
@pytest.mark.parametrize("bad", ["", "abc", "0", "-5"])
def test_unusable_override_falls_back_to_default(self, monkeypatch, bad):
monkeypatch.setenv("PRAISONAI_SUBPROCESS_TIMEOUT", bad)
# Must not raise, and must not adopt a non-positive timeout that would
# kill every command instantly.
result = run_guarded([sys.executable, "-c", "print('fine')"])
assert result.returncode == 0
assert DEFAULT_TIMEOUT > 0
def test_explicit_timeout_beats_env(self, monkeypatch):
monkeypatch.setenv("PRAISONAI_SUBPROCESS_TIMEOUT", "30")
result = run_guarded(
[sys.executable, "-c", "import time; time.sleep(30)"], timeout=0.3
)
assert result.returncode == TIMEOUT_RETURNCODE
class TestCompatibility:
"""Call sites read .returncode/.stdout/.stderr unconditionally."""
def test_result_is_a_real_completed_process(self):
result = run_guarded([sys.executable, "-c", "import time; time.sleep(5)"], timeout=0.3)
assert isinstance(result, subprocess.CompletedProcess)
assert result.args == [sys.executable, "-c", "import time; time.sleep(5)"]
# Call sites read .stdout/.stderr unconditionally; both must be str.
assert isinstance(result.stdout, str)
assert isinstance(result.stderr, str)
class TestKanbanGitTimeout:
"""The dispatcher's git ceiling must bound hangs without failing slow work."""
def test_default_is_generous(self):
from praisonai_bot.gateway.kanban_dispatcher import _git_timeout
# Worktree add / merge on a large repo can legitimately run for
# minutes; the bound exists to stop an indefinite hang, not slow work.
assert _git_timeout() >= 300.0
def test_env_override(self, monkeypatch):
from praisonai_bot.gateway.kanban_dispatcher import _git_timeout
monkeypatch.setenv("PRAISONAI_KANBAN_GIT_TIMEOUT", "42")
assert _git_timeout() == 42.0
@pytest.mark.parametrize("bad", ["", "abc", "0", "-1"])
def test_unusable_override_falls_back(self, monkeypatch, bad):
from praisonai_bot.gateway.kanban_dispatcher import _git_timeout
monkeypatch.setenv("PRAISONAI_KANBAN_GIT_TIMEOUT", bad)
assert _git_timeout() >= 300.0
def test_run_git_is_bounded(self, monkeypatch):
"""_run_git must route through run_guarded, not raw subprocess.run."""
from praisonai_bot.gateway import kanban_dispatcher as kd
seen = {}
def fake_run_guarded(cmd, **kwargs):
seen["cmd"] = cmd
seen["timeout"] = kwargs.get("timeout")
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
monkeypatch.setattr(kd, "run_guarded", fake_run_guarded)
dispatcher = kd.KanbanDispatcher.__new__(kd.KanbanDispatcher)
dispatcher._run_git("status")
assert seen["cmd"][0] == "git"
assert seen["timeout"] is not None and seen["timeout"] > 0
class TestDescendantTermination:
"""A grandchild must not outlive the kill, nor hold the caller open.
subprocess.run's own timeout path kills only the direct child: on POSIX it
then wait()s (orphaning the grandchild), and on Windows it communicate()s
with no timeout, which blocks until the grandchild releases the inherited
pipe. Both are why run_guarded kills the whole process group.
"""
def test_returns_promptly_despite_a_surviving_descendant(self):
import time
script = (
"import subprocess, sys, time; "
"subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)']); "
"time.sleep(60)"
)
started = time.monotonic()
result = run_guarded([sys.executable, "-c", script], timeout=1.0)
elapsed = time.monotonic() - started
assert result.returncode == TIMEOUT_RETURNCODE
# Generous bound: the point is that it does not wait out the 60s
# sleeps, on any platform.
assert elapsed < 20, f"took {elapsed:.1f}s; descendant blocked the reap"
def test_grandchild_is_actually_killed(self, tmp_path):
marker = tmp_path / "grandchild-survived.txt"
grandchild = (
f"import time; time.sleep(4); "
f"open({str(marker)!r}, 'w').write('alive')"
)
script = (
"import subprocess, sys, time; "
f"subprocess.Popen([sys.executable, '-c', {grandchild!r}]); "
"time.sleep(60)"
)
result = run_guarded([sys.executable, "-c", script], timeout=1.0)
assert result.returncode == TIMEOUT_RETURNCODE
# Outlive the grandchild's sleep. If the group kill worked it never
# gets to write; if only the direct child died, the marker appears.
import time
time.sleep(6)
assert not marker.exists(), "grandchild survived the group kill"
class TestCheck:
"""check= matches subprocess.run, except a timeout still returns."""
def test_check_raises_on_nonzero(self):
with pytest.raises(subprocess.CalledProcessError):
run_guarded([sys.executable, "-c", "import sys; sys.exit(2)"], check=True)
def test_check_is_off_by_default(self):
result = run_guarded([sys.executable, "-c", "import sys; sys.exit(2)"])
assert result.returncode == 2
def test_timeout_returns_even_with_check(self):
# A timeout is reported as a value, never an exception, so callers
# branching on returncode keep working.
result = run_guarded(
[sys.executable, "-c", "import time; time.sleep(30)"],
timeout=0.4,
check=True,
)
assert result.returncode == TIMEOUT_RETURNCODE
class TestNonFiniteTimeoutOverrides:
"""An override that parses as positive can still be unbounded.
float("inf") passes a bare `value > 0` check, which would hand back an
infinite timeout and silently reinstate the hang this module prevents.
"""
@pytest.mark.parametrize("bad", ["inf", "Infinity", "+inf", "1e400", "nan"])
def test_subprocess_timeout_rejects_non_finite(self, monkeypatch, bad):
import math
from praisonai_bot._guarded_subprocess import DEFAULT_TIMEOUT, _default_timeout
# Assert on the resolver, not on run_guarded with an explicit
# timeout= -- an explicit argument bypasses the env var entirely, so
# that spelling would pass even with the bug present.
monkeypatch.setenv("PRAISONAI_SUBPROCESS_TIMEOUT", bad)
resolved = _default_timeout()
assert math.isfinite(resolved)
assert resolved == DEFAULT_TIMEOUT
@pytest.mark.parametrize("bad", ["inf", "Infinity", "+inf", "1e400", "nan"])
def test_git_timeout_rejects_non_finite(self, monkeypatch, bad):
import math
from praisonai_bot.gateway.kanban_dispatcher import _git_timeout
monkeypatch.setenv("PRAISONAI_KANBAN_GIT_TIMEOUT", bad)
resolved = _git_timeout()
assert math.isfinite(resolved)
assert resolved >= 300.0
def test_default_resolver_stays_finite_without_override(self, monkeypatch):
import math
from praisonai_bot._guarded_subprocess import _default_timeout
monkeypatch.delenv("PRAISONAI_SUBPROCESS_TIMEOUT", raising=False)
assert math.isfinite(_default_timeout())
+2 -2
View File
@@ -1416,7 +1416,7 @@ wheels = [
[[package]]
name = "praisonai-bot"
version = "0.1.1"
version = "0.1.2"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -1498,7 +1498,7 @@ provides-extras = ["gateway", "bot", "bot-whatsapp-web", "all"]
[[package]]
name = "praisonaiagents"
version = "1.7.1"
version = "1.7.3"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
@@ -1 +1 @@
__version__ = "0.1.1"
__version__ = "0.1.2"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-browser"
version = "0.1.1"
version = "0.1.2"
description = "Browser automation for PraisonAI — Chrome extension bridge, CDP, and Playwright agents extracted from the praisonai wrapper."
readme = "README.md"
license = {text = "MIT"}
+2 -2
View File
@@ -920,7 +920,7 @@ wheels = [
[[package]]
name = "praisonai-browser"
version = "0.1.1"
version = "0.1.2"
source = { editable = "." }
dependencies = [
{ name = "click" },
@@ -964,7 +964,7 @@ provides-extras = ["server", "playwright", "all"]
[[package]]
name = "praisonaiagents"
version = "1.7.1"
version = "1.7.3"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
@@ -12,6 +12,6 @@ the remaining terminal-agent modules, with PEP 562 shims left behind at the
old ``praisonai.*`` paths for backward compatibility.
"""
__version__ = "0.1.1"
__version__ = "0.1.2"
__all__ = ["__version__"]
@@ -290,6 +290,18 @@ class PluginRegistry(Generic[T]):
except ValueError:
return False
def has(self, name: str) -> bool:
"""Return True if ``name`` is registered, without loading it.
Unlike :meth:`is_available`, this never materialises the plugin — it
only checks the registered names/aliases — so it is safe for cheap
membership tests (e.g. cross-registry ``__getattr__`` dispatch).
"""
normalized_name = name.lower()
with self._lock:
canonical_name = self._aliases.get(normalized_name, normalized_name)
return canonical_name in self._items or canonical_name in self._loaders
def list_aliases(self) -> Dict[str, str]:
"""List all aliases and their target plugins.
@@ -791,7 +791,7 @@ class AsyncTUI:
)
self._registry = create_default_registry(
self._BUILTIN_COMMANDS, include_custom=True
self._BUILTIN_COMMANDS, include_custom=True, include_skills=True
)
except Exception as exc: # pragma: no cover - defensive
logger.debug("Command registry unavailable: %s", exc)
@@ -1883,10 +1883,20 @@ Example: /handoff code "refactor the auth module" """
from prompt_toolkit.history import FileHistory
from prompt_toolkit.completion import Completer, Completion
# Create completer for commands and files. Derive the list from the
# single source of truth (_BUILTIN_COMMANDS) so registration and
# autocomplete never drift (e.g. code-review/security-review).
# Create completer for commands and files. Start from the built-ins
# (single source of truth so registration and autocomplete never drift,
# e.g. code-review/security-review) and extend with discovered registry
# commands — custom ``.praisonai/commands/*.md`` and installed skills —
# so typing ``/`` offers them too. Degrades to built-ins on any failure.
commands = list(self._BUILTIN_COMMANDS.keys())
registry = self._get_registry()
if registry is not None:
try:
for c in registry.list_commands():
if c.name not in commands:
commands.append(c.name)
except Exception as exc: # pragma: no cover - defensive
logger.debug("Skipping registry completions: %s", exc)
workspace_files = self._workspace_files
class PraisonCompleter(Completer):
@@ -148,13 +148,56 @@ class CustomCommandSource:
return commands
def _make_skill_handler(skill: Any) -> Callable[[str], Optional[str]]:
"""Build a handler that turns a discovered skill into a runnable prompt.
The interactive surfaces dispatch any registry command by calling its
``handler`` and feeding the returned text to the agent. A skill therefore
needs a handler that renders its ``SKILL.md`` body (plus the user's ``args``)
into a prompt, matching how :class:`CustomCommandSource` renders custom
commands. Without this, ``/my-skill`` would appear in the menu but fall
through to "Unknown command" because both dispatchers require a handler.
"""
def handler(args: str) -> Optional[str]:
name = str(getattr(skill, "name", "") or "")
description = getattr(skill, "description", "") or ""
body = ""
path = getattr(skill, "path", None)
if path is not None:
try:
from praisonaiagents.skills.parser import (
find_skill_md,
parse_frontmatter,
)
skill_md = find_skill_md(path)
if skill_md is not None:
_, body = parse_frontmatter(skill_md.read_text(encoding="utf-8"))
except Exception as exc: # pragma: no cover - defensive
logger.debug("Skill body unavailable for %s: %s", name, exc)
sections = [f"Use the '{name}' skill."]
if description:
sections.append(description)
if body:
sections.append(body)
if args:
sections.append(args)
return "\n\n".join(section for section in sections if section)
return handler
class SkillCommandSource:
"""Optional source exposing discovered skills as invocable commands.
Skills live in the core SDK (``praisonaiagents.skills``); this source only
*consumes* them. It fails silently (returns ``[]``) when the skills module
or any project skills are unavailable, so it never adds import cost or
breaks surfaces that do not use skills.
breaks surfaces that do not use skills. Each skill carries a handler that
renders its ``SKILL.md`` body into a prompt so ``/name`` invocation works in
every interactive surface, not just the completion menu.
"""
def discover(self) -> List[Command]:
@@ -180,6 +223,7 @@ class SkillCommandSource:
description=getattr(skill, "description", "") or "Skill",
kind=CommandKind.SKILL,
source="skill",
handler=_make_skill_handler(skill),
)
)
return commands
@@ -271,7 +271,18 @@ class InteractiveREPL:
from .command_registry import create_default_registry
self._registry = create_default_registry(
DEFAULT_COMMANDS, include_custom=True
DEFAULT_COMMANDS, include_custom=True, include_skills=True
)
# Surface custom/skill/mcp commands in the ``/`` completion menu
# too — the built-ins are registered at init, but discovered
# commands (e.g. installed skills) must be added here so typing
# ``/`` offers them alongside the built-ins.
self.io.add_commands(
{
c.name: c.description
for c in self._registry.list_commands()
if c.kind.value != "builtin"
}
)
except Exception as exc: # pragma: no cover - defensive
logger.debug("Command registry unavailable: %s", exc)
@@ -627,7 +638,12 @@ class InteractiveREPL:
tools_count=tools_count,
session_id=self._session_id
)
# Warm the command registry so discovered commands (custom + installed
# skills) populate the ``/`` completion menu before the first slash is
# typed, not only after the first custom/skill invocation.
self._get_registry()
while self._running:
try:
# Get input (SYNC - this is the key!)
@@ -1265,36 +1265,83 @@ class ToolResolver:
# Context-local resolver for multi-project safety
import contextlib
import contextvars
from collections import OrderedDict
from typing import Iterator
_resolver_var: contextvars.ContextVar[Optional[ToolResolver]] = contextvars.ContextVar(
"tool_resolver", default=None,
)
# CWD-anchored cache for the *unscoped* default. Keyed by the resolved
# ``tools.py`` path (which encodes the current working directory), so a caller in
# ``/tenants/A`` and one in ``/tenants/B`` get distinct resolvers instead of the
# first caller pinning its CWD into the root context for the whole process.
_UNSCOPED_CACHE: "OrderedDict[str, ToolResolver]" = OrderedDict()
_UNSCOPED_LOCK = threading.Lock()
_UNSCOPED_MAX = 32
def _get_default_resolver() -> ToolResolver:
"""Per-context resolver. Falls back to a fresh ToolResolver per context,
so changing CWD between agents / requests is honoured.
Each context (agent/task/request) gets its own resolver anchored to the
working directory at the time of first use in that context. This prevents
the singleton bug where the first caller locks in their CWD for the entire process.
For test isolation or multi-project CLIs, create explicit resolver
instances instead of using this cached default.
"""Return the scoped resolver, else a CWD-anchored resolver for THIS caller.
Never writes to ``_resolver_var``: ``ContextVar.set()`` from an outermost
context leaks into the root context and is inherited by every task/thread
that lacks its own value, which would pin the first caller's CWD
process-wide (the exact singleton bug this indirection was meant to avoid).
Callers who want a fixed binding for the duration of a request/task open an
explicit scope with :func:`scoped_resolver`.
For test isolation or multi-project CLIs, create explicit ``ToolResolver``
instances instead of relying on this default.
"""
resolver = _resolver_var.get()
if resolver is None:
scoped = _resolver_var.get()
if scoped is not None:
return scoped
key = str(Path("tools.py").resolve())
with _UNSCOPED_LOCK:
cached = _UNSCOPED_CACHE.get(key)
if cached is not None:
_UNSCOPED_CACHE.move_to_end(key)
return cached
resolver = ToolResolver()
_resolver_var.set(resolver)
return resolver
_UNSCOPED_CACHE[key] = resolver
_UNSCOPED_CACHE.move_to_end(key)
while len(_UNSCOPED_CACHE) > _UNSCOPED_MAX:
_UNSCOPED_CACHE.popitem(last=False)
return resolver
@contextlib.contextmanager
def scoped_resolver(resolver: ToolResolver) -> Iterator[ToolResolver]:
"""Bind ``resolver`` for the duration of the ``with`` block.
Server request handlers, ``RunContext``, and per-tenant middleware wrap their
work with this so tool resolution honours the caller's CWD/project without
leaking into sibling contexts::
with scoped_resolver(ToolResolver()):
...
"""
token = _resolver_var.set(resolver)
try:
yield resolver
finally:
_resolver_var.reset(token)
def reset_default_resolver() -> None:
"""Explicit invalidation hook for daemons / IDE plugins switching projects.
Call this when changing working directories or switching between projects
to ensure the next tool resolution uses the new CWD.
Clears any scoped binding in the current context and drops the CWD-anchored
default cache so the next resolution rebuilds against the current CWD.
"""
_resolver_var.set(None)
with _UNSCOPED_LOCK:
_UNSCOPED_CACHE.clear()
# Convenience functions that use cached default resolver for performance
Loaded 100 of 252 files, more files were not shown because too many files have changed in this diff. Show more