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
praisonai-triage-agent[bot] fd4796ea6c Release v4.7.4 2026-08-28 22:11:56 +00:00
Mervin Praison 0cdd47af09 Merge pull request #4582 from MervinPraison/fix/mobile-tier2-survivors
test(mobile): the tier-2 survivors, and a fifth fake that could lie
2026-08-28 22:23:35 +01:00
MervinPraisonandClaude Opus 5 7fa24fe269 test(mobile): the tier-2 survivors, and a fifth fake that could lie
The remainder of the package-wide sweep's genuine survivors.

  view-model.ts   `truncate(firstLine(output), …)` -> `truncate(output, …)`
      The tool preview keeps its newlines, so a one-line row renders as
      several. Every `ls`, every stack trace, every file read breaks the
      transcript's layout.

  decode.ts   drop `|| type === ""`
      Survived my OWN earlier test, which asserted only that the frame was
      refused. It still is -- but as `unknown_event` with an empty detail
      instead of `missing_type` with the key list. The dropped-events row then
      blames the engine for sending something unrecognised when what arrived
      had no type at all, and a reason that names the wrong failure sends
      whoever reads it to the wrong place.

  bundle.ts   `isMarked` `&&` -> `||`
      "half and "half both report as marked-missing, so a translator's literal
      bracket reads as a missing key and the report names strings that are
      present.

  segment.ts   `end === 0` -> `end === 1`
      A chunk whose only character is a terminator reports incomplete, so
      `completedLength` returns 0 and the screen-reader announcement stalls.
      CJK writes short sentences; a lone "。" is a whole one.

  format.ts   `seconds < 45` -> `< 44`, and session.ts `<= 60` -> `< 60`
      Threshold and boundary, each the entire contract of its function, and
      neither had a value placed on it. format-intl.ts has the same 45s
      boundary and is pinned separately -- the two must not drift.

AND THE FIFTH FAKE

`testing/src/fake-http.ts` had no test file. Two of its behaviours are
load-bearing for every engine test written against it:

The route table picks the LONGEST matching suffix, so `/chats` does not shadow
`/chat`. Inverting it silently mis-routes a test to the wrong handler and the
test still passes -- against the wrong thing. I have already been bitten by
this file's suffix matching once this session, when a handler registered for
`/approve` never matched `/approve/{id}` and a test went green without
exercising what it was written for.

And `streamOf` chunks its body deliberately, "because a frame split across
chunks is the normal case on a real network". Emitting an extra zero-length
chunk changes what the SSE reader is exercised with -- and that reader's
pending-CR logic is precisely sensitive to where boundaries fall. A fake that
stops splitting properly can mask the CRLF defect this package shipped once
already.

That is the fifth fake here to get its own tests after being caught
misrepresenting what it stands in for, after setTimer's delay, every's period,
advance's amount, and the fake DOM that appended instead of ordering.

1121 tests on node 22.23 and 24.12. Eight mutations confirmed to fail.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 22:19:09 +01:00
Mervin Praison 78c4848f43 Merge pull request #4581 from MervinPraison/fix/mobile-engines-remaining
test(mobile): the engines layer's remaining failure paths
2026-08-28 22:14:53 +01:00
MervinPraisonandClaude Opus 5 49e236fd58 test(mobile): the engines layer's remaining failure paths
`engines/` measured 46.7% genuine mutation survival -- more than double the
package average and 4.5x core -- with six of seven survivors on transport
failure or early exit. The last three.

  praisonai-ts/engine.ts   `if (signal.aborted) controller.abort()` deleted
      The `else` branch attaches an abort LISTENER, which never fires for a
      signal that went down before it was attached. So a run handed an
      already-aborted signal streams to completion and PERSISTS an answer
      nobody is waiting for. Reachable through dispose-then-new-run, and
      through Stop followed immediately by Send.

  praisonai-ts/classify.ts   `status >= 500` -> `> 500`
      A plain 500 -- the most common provider failure there is -- classifies
      as `internal`, whose recovery is `none`. The user is told something went
      wrong and offered no way to try again. 502 and 503 are unaffected, so
      the boundary lands exactly on the case that matters most.

  remote-http/readiness.ts   `probe.status !== 200` -> `> 200`
      A sub-200 status (100, 101, 199) falls through and is classified from its
      BODY, so a websocket upgrade or a "continue" reports ready. The engine is
      then offered as usable and every request to it fails.

Each is paired: a live signal must still stream, a 4xx must NOT be transport
(offering Retry for a revoked key can never succeed), and a 200 must still be
classified from its body.

A NEW BREAK MODE FOR THE ENGINE CONTRACT

`start_only` -- a run that opens and then simply stops, with no terminal event
at all. It is now asserted to redden "a run emits start first and exactly one
terminal event last" by name.

Writing it also settled a survivor I had listed as a gap: weakening that case's
`events.length >= 2` to `>= 1` does NOT let a start-only engine through. Nor
does weakening the terminal COUNT as well. The case catches it either way, so
the length assertion is redundant rather than unguarded -- worth knowing, and
worth not chasing.

1107 tests on node 22.23 and 24.12. Three mutations confirmed to fail; the
fourth proved redundant rather than surviving.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 22:10:27 +01:00
Mervin Praison 5b596a642f Merge pull request #4580 from MervinPraison/fix/mobile-final-tier1
fix(mobile): a button that read Send and behaved as Stop, and four more
2026-08-28 22:06:42 +01:00
MervinPraisonandClaude Opus 5 28bb6e943e fix(mobile): a button that read Send and behaved as Stop, and four more
The remaining tier-1 survivors from the package-wide sweep.

  main.ts   `? actionStop : actionSend` transposed
      The button READS "Send" while the turn is streaming, and tapping it stops
      the run. The line below sets `dataset.action` and is untouched, so label
      and behaviour disagree -- and nothing in 1091 tests read the label.

  transcript.ts   `blocks.slice(0, -1)` -> `slice(0, -2)`
      Appending to the trailing text block dropped the block BEFORE it too. Any
      answer that keeps talking after a tool call loses the tool row and its
      output: the user sees two paragraphs and no evidence a tool ever ran.

  a11y/names.ts   `row.title === ""` -> `!== ""`
      Both directions at once: a real title announced as "Untitled", and an
      untitled row announced as an EMPTY accessible name. A nameless row reads
      as just "button" and cannot be told apart from the one above it, which
      the comment two lines above says is the whole point of the function.

  tauri/shell.ts   `sameInsets` without `right`
      A landscape notch appearing on the right is deduped away as "no change",
      so content sits under it after a rotation.

  tauri/shell.ts   `coerceInsets` requiring ALL four edges
      A native side that sends only what moved has every event discarded, and
      the insets stay at whatever the first CSS read gave them -- the composer
      under the home indicator for the life of the app. Its pair: an EMPTY
      payload must mean "re-read the CSS", not "every inset is zero".

WHERE EACH TEST HAD TO GO, AND WHY THAT MATTERS

Two of these could not live in the shared shell contract, and finding out why
was the useful part.

`emitInsets` takes a full `SafeAreaInsets`, so the harness cannot express a
PARTIAL native payload -- which is exactly the input `coerceInsets`'s guard is
about. And the inset dedupe is a Tauri-only optimisation: the fake and web
shells republish an identical payload, so asserting otherwise in the shared
contract failed two of three implementations. I wrote it there first and had to
move it.

A contract can only assert what every implementation owes. Behaviour that is
one adapter's alone needs its own test, and mistaking the two is how a case
either fails for the wrong shells or never reaches the branch at all.

1100 tests on node 22.23 and 24.12. Seven mutations confirmed to fail.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 22:02:21 +01:00
Mervin Praison 704cb8af93 Merge pull request #4579 from MervinPraison/fix/mobile-engine-error-paths
fix(mobile): the engines layer was the weakest, and its error paths were unpinned
2026-08-28 21:57:08 +01:00
MervinPraisonandClaude Opus 5 7d42285958 fix(mobile): the engines layer was the weakest, and its error paths were unpinned
A package-wide sweep of 154 mutations on current main put genuine survival at
23.8%, against 26.3% originally -- an improvement inside the sampling error,
so flat package-wide. The layer split is the actionable result:

  engines   46.7%   <-- weakest, 4.5x core
  adapters  29.2%
  ui        20.5%
  app       15.4%
  core      10.3%   <-- strongest

Six of the seven engine survivors were on TRANSPORT-FAILURE or EARLY-EXIT
paths. The conformance harness covers the happy path thoroughly; nothing
covered these, and a phone spends a disproportionate amount of its life there.

  postOk: `if (response.status !== 200) return false` deleted
      Every non-200 -- 202, 401, 500, 502 -- reports SUCCESS. The UI announces
      a stop that never happened and marks an approval sent that the engine
      never received. Both callers were written to trust this boolean.

  the reader's `cancel()` in the finally, deleted
      On New chat or Stop the consumer breaks out of the loop; without the
      cancel the engine keeps generating into a socket nobody drains, and keeps
      billing. Proved with a stream that records its own cancellation.

  `{ ...parsed.value, type: frame.event }` transposed
      A frame whose data object carries its own `type` is then decoded as THAT
      type. A delta arriving as an ill-formed `error` is dropped silently, so
      text vanishes from the answer with no diagnostic at all.

AND THE CHECKER STOPPED CHECKING ITSELF

`tools/check-boundaries.mjs` walked `.ts` and `.mjs`. Dropping the `.mjs` half
survived: it silently stops walking every .mjs in the package -- and `tools/`
is entirely .mjs -- while still printing "136 files checked, no violations".

That is verbatim the failure boundaries.json's own comment warns about: "a rule
that stops covering new code while still reporting a clean pass is worse than
no rule." The walk is now `sourceFilesUnder(base, roots)` in depgraph.mjs so a
test can call it, following the same move already made for `ungovernedRootsIn`.

ONE TEST OF MINE PASSED FOR THE WRONG REASON

The refusal test registered `http.on("/approve", ...)`, and the fake matches by
URL SUFFIX -- the engine posts to `/approve/{approvalId}`, so nothing matched
and every request 404'd. A 404 is not 200, so the test went green while never
exercising the status check it was written for. Its paired "a 200 with an ok
body IS accepted" case failed, which is the only reason I noticed.

1091 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 21:52:45 +01:00
Mervin Praison 2a362f90f8 Merge pull request #4578 from MervinPraison/fix/mobile-tier1-survivors
fix(mobile): five tier-1 survivors, and a shell that could forward what it did not validate
2026-08-28 21:06:43 +01:00
praisonai-triage-agent[bot]andMervin Praison 8d72793f96 fix(mobile): shared shell contract now reads the forwarded URL, not just the settle
The padded-URL case only asserted resolve/reject, so the fake and web shells
forwarded the raw padded string to the OS and still passed — the exact
validate-one-form/forward-another seam the Tauri-specific test was written for.
Surface the forwarded URLs through ShellHarness.forwarded() and assert the
trimmed value, then trim in the web and fake shells to match the port contract.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 20:04:15 +00:00
MervinPraisonandClaude Opus 5 b0aaae3c71 fix(mobile): five tier-1 survivors, and a shell that could forward what it did not validate
The production-side findings from the same re-measure, verified live against
merged main before any were touched.

  main.ts    `setChat(mintChatId())` -> `setChat("unassigned")`
      "unassigned" is what controller.ts uses to mean "nobody has said which
      conversation this is". Shipping it to an engine keying history by chat_id
      merges conversations. The existing test only asserted two ids DIFFER --
      and the launch id is real now, so a placeholder on turn two passed it.

  main.ts    delete `input.value = ""`
      The sent text stays in the composer, so the next Send tap re-submits the
      SAME question. The user double-sends and pays twice, with nothing on
      screen to say so.

  platform.ts   `view.localStorage` -> `view.sessionStorage`
      Every chat dies with the tab -- on a phone, every time the OS reclaims
      the webview. The file's own comment is about localStorage EVICTION being
      the risk; nothing asserted which store it reached for.

  web/http.ts   `status: response.status` -> `status: 200`
      Every 401, 403, 429 and 502 reported as success. The engine reads the
      status to choose `auth` vs `transport`, so the whole recovery
      distinction -- go to settings, or retry -- was fed a constant. The tests
      that DO cover that distinction drive the fake http, so the one place the
      real status is read had nothing on it.

  tauri/shell.ts  `url.trim()` -> `url`
      The interesting one. It survived EVERY contract case, including the new
      padded-URL case I wrote for it, because a contract can only observe
      REJECTION and the allowlist tolerates padding either way. What changes is
      what reaches the OS.

A URL validated in one form and forwarded in another is the shape of a
scheme-confusion bypass, and nothing in the suite read what the bridge was
handed. The probe bridge now records it, and the assertion is on the forwarded
string rather than on whether the promise settled. Mutating the forward to pass
the raw `url` instead of `target` also fails now -- so the test observes the
seam, not just the trim.

That is the general lesson of this one: `doesNotReject` proves a call did not
throw. It does not prove the call did the right thing, and for a security
boundary that gap is the whole question.

1085 tests on node 22.23 and 24.12. Six mutations confirmed to fail.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 20:55:27 +01:00
Mervin Praison 14011299c3 Merge pull request #4577 from MervinPraison/fix/mobile-shell-contract-guard
fix(mobile): the shell contract was the one the fixture never registered
2026-08-28 20:47:30 +01:00
MervinPraisonandClaude Opus 5 a73d135393 fix(mobile): the shell contract was the one the fixture never registered
A re-measure of the regions a previous sweep named. The production rates DID
fall -- main.ts 74.4% -> 28.6%, adapters 44.3% -> 24.1%, i18n 43.8% -> 34.8%,
boot.ts 30.8% -> 22.2%. Those fixes were structural.

The survival moved into the machinery I added to guard them.

THE SHELL CONTRACT WAS COMPLETELY UNDEFENDED

`contract-fixture.ts` registered secrets, storage and time. Not shell -- the
largest of the four, 33 cases, including seven `openExternal` refusals. No
break mode, no floor. Measured: every assertion in it deletable, the whole
security section removable, the entire contract replaceable with
`assert.ok(shell)`, green each time. 57 of 61 assertions across all four
contracts were free; exactly four were load-bearing.

A `javascript:` URL reaching `openExternal` inside a webview is script
execution in the app's own origin with the user's session, from a URL that
routinely arrives from a model, a tool result, or pasted text. The RULE is in
core/src/ports/shell.ts and both adapters call it. What was undefended is the
contract proving they do -- and the next shell, React Native, would be written
against a contract that cannot fail.

Registered now, with three break modes: a shell that opens anything, a back
stack in registration order, and a listener count that cannot see a leak.
Built by wrapping a real web shell rather than hand-rolling a ShellPort, so
the fixture cannot drift from the interface it exercises.

TWO HOLES THE FLOOR HAD

`# SKIP` is reported as `ok` in TAP, so changing `test(` to `test.skip(` on a
security case kept the run green -- a case could be disabled rather than
deleted and the count never moved. The floor now excludes skips and todos.

And the meta-test filtered for `not ok` lines and matched separately, so
neutering the filter let a PASSING line satisfy the assertion: it went green
precisely when the contract stopped catching anything. The prefix is part of
the pattern now.

A CONTRACT CASE TOO WEAK TO CATCH ITS OWN MUTATION

"the most recently registered back handler gets first refusal" registers two
DIFFERENT closures, so `indexOf` and `lastIndexOf` behave identically and the
mutation between them survived in BOTH shells. React StrictMode double-invokes
effects, so the same reference registered twice is ordinary. The new case
registers A, B, A and drops the second A; removing the first instead inverts
the stack and the back press goes to the wrong screen.

WHERE THIS STOPS

Lowering a floor, or rewriting the meta-test's assertion back to its weaker
form, is not caught by anything -- and cannot be without another guard above
it. The regress has to end somewhere. It ends at code review, and this commit
moves the line up: the contracts, the cases, the break table and the skip
counting are all defended mechanically now. Only edits to the guard itself are
not, and those read as what they are in a diff.

1077 tests on node 22.23 and 24.12. boundaries: 136 files, no violations.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 20:37:21 +01:00
MervinPraison b7fefee39f wip 2026-08-28 20:30:22 +01:00
Mervin Praison 3d60e5748a Merge pull request #4576 from MervinPraison/fix/mobile-format-intl
test(mobile): the seven no-Intl fallbacks were dead to the suite
2026-08-28 19:33:02 +01:00
praisonai-triage-agent[bot]andMervin Praison 6bdbb70527 test(mobile): close the two-branch gap Greptile flagged in the no-Intl control
The `every no-Intl fallback differs from its formatted twin` control only
compared number/count/date. Elapsed and relative-time render byte-identical in
English through both Intl and the fallback ("1h 02m", "10 minutes ago"), so an
"en" twin can't distinguish the fallback branch from a successful memo(). Added
de-DE twins (comma decimal, native relative phrasing) that genuinely differ from
the ASCII/English en_US fallback, so the control now guards all seven paths.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 18:31:14 +00:00
MervinPraisonandClaude Opus 5 5e277ff3ef test(mobile): the seven no-Intl fallbacks were dead to the suite
The last of the regions a mutation sweep named. `format-intl.ts` measured 67%
survival for one reason: every `fmt === null` branch is unreachable on a host
with full ICU, which is every host a test runs on. `memo(...)` always returns a
formatter, so seven fallbacks never executed.

They are not exotic old-WebView paths. Every Intl constructor throws
RangeError on an underscore tag, and locale.ts says in its own header that a
stored preference reaches us as "en_US" as often as "en-US". These are one bad
settings value away, in production, today.

Six mutations that survived, and what each shipped:

  String(value) -> ""              a number renders as nothing
  String(whole) -> whole + 1       every count off by one
  padStart(2, "0") dropped         "1h 2m" instead of "1h 02m", which scans
                                   and sorts wrongly in a list of durations
  toFixed(1) dropped               sub-10s durations lose their decimal, and
                                   that decimal is the whole reason the branch
                                   exists -- "3s" hides 2.5 from 3.4
  slice(0, 10) -> slice(0, 7)      every date in the chat list loses its day
                                   and reads as a month
  formatRelativeFromStrings        "10 minutes ago" becomes "10"
    replaced by the raw value

No export was needed, unlike `segment.ts` and `locale.ts` -- these functions
are already public and the fallback is reachable through the ordinary API. The
gap was never structural; nothing had passed a locale that fails.

Each assertion is paired with a check that the FORMATTED output differs from
the fallback, so the block cannot start passing for the wrong reason if
`memo()` ever succeeds for "en_US" or the formatted path degrades.

One of my expectations was wrong rather than the code: I asserted 5.25 seconds
renders "5.2s"; `toFixed(1)` rounds it to "5.3s".

1071 tests on node 22.23 and 24.12. Tests only; no product source changed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 19:23:07 +01:00
Mervin Praison 16598c34ec Merge pull request #4575 from MervinPraison/fix/mobile-main-coverage
fix(mobile): the chrome was the worst-covered file in the package
2026-08-28 19:20:12 +01:00
MervinPraisonandClaude Opus 5 62d0403a15 fix(mobile): the chrome was the worst-covered file in the package
`app/src/main.ts` measured 74% mutation survival -- the highest of any file.
The tests it had each pinned one previously-reported defect and nothing around
it, which is what happens when tests are written from a bug list rather than
from what the code does.

Eight mutations that survived, and what each shipped:

  phase === "streaming" ? "stop" : "send"  ->  "send"
      THE STOP BUTTON NEVER APPEARS. A run cannot be cancelled from the UI at
      all; it keeps generating and keeps billing.

  keyboardHeightPx -> 0
      The composer never lifts above the keyboard. You type behind it.

  insets.top -> insets.bottom
      The top safe-area is read from the bottom, so content sits under the
      notch. Both custom properties were written and neither was checked.

  render = diff.next removed
      The render state never advances, so every publish re-inserts every row.
      A three-delta answer paints as three separate paragraphs of the same
      growing text.

  transcript.textContent = "" removed
      New chat leaves the previous conversation on screen.

  input.value.trim() -> input.value
      Whitespace-only sends start real turns against the engine.

  root.textContent = "" removed (fatal screen)
      The crash screen appends UNDER the corpse of the dead UI rather than
      replacing it, so the app looks alive and is not.

AND ONE FOUND WHILE WRITING THEM

New chat cleared the transcript and not the live regions. `announcer =
initialAnnouncer` resets the state that decides what to SAY next; it does not
empty the regions themselves. So the previous conversation's answer stayed in
the accessibility tree of what the user believes is an empty chat -- not
announced again, but still there for anyone navigating the page.

I found it because my own test asserted on the whole document text and failed
while the transcript was correctly empty. The test now asserts on the
transcript AND on the regions, which is what it should have done from the
start.

1063 tests on node 22.23 and 24.12. Eight mutations confirmed to fail.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 19:10:16 +01:00
Mervin Praison db56a5941a Merge pull request #4574 from MervinPraison/fix/mobile-rtl-and-fixtures
fix(mobile): thirteen RTL languages rendered the wrong way, and the fake DOM had no self-tests
2026-08-28 19:05:49 +01:00
praisonai-triage-agent[bot]andMervin Praison 353f9a481c fix(mobile): limited-ICU host skips the Intl-parity test instead of failing
The corpus test's `assert.ok(compared > 20)` hard-failed on a small-ICU
build where `Intl.Locale.textInfo` is absent for most tags -- the exact
host the test's own header promised to skip. Replaced the assertion with
`t.skip()` below the threshold, so a full-ICU host still enforces parity
across ~50 tags while a degraded host reports nothing rather than failing
for the wrong reason.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 18:01:34 +00:00
MervinPraisonandClaude Opus 5 a42def0a81 fix(mobile): thirteen RTL languages rendered the wrong way, and the fake DOM had no self-tests
Two findings from a targeted re-measure, both about code that only runs where
no test can see it.

THIRTEEN LANGUAGES RENDERED LEFT TO RIGHT

`direction()` prefers `Intl.Locale.textInfo` and falls back to a table. The
fallback is consulted only when textInfo is ABSENT -- which is never, on any
host a test runs on. So the table could disagree with Intl indefinitely and
nothing would notice.

Asking both and comparing across a corpus: thirteen disagreed, every one of
them RTL by Intl and LTR by the table.

  aeb acm ajp apc ary arz   five spoken Arabic varieties -- which is what a
                            phone in those regions actually reports, rather
                            than plain "ar"
  bal glk haz lrc mzn skr   Baluchi, Gilaki, Hazaragi, Northern Luri,
                            Mazanderani, Saraiki
  rhg                       Rohingya, whose Hanifi script is RTL

On an older Android or iOS WebView without textInfo, every one of those got
the entire UI laid out the wrong way round. A prior sweep reported eight; a
wider corpus found five more, which is the argument for the test rather than
the fix: it now asserts the table agrees with Intl across ~50 tags, so the
NEXT drift fails rather than the next audit finding it. Tags this Node cannot
answer for are skipped, so a small-ICU build reports nothing rather than
failing for the wrong reason.

THE FAKE DOM HAD TWELVE UNGUARDED BEHAVIOURS

Every composition-root test rests on `testing/src/fake-dom.ts`, and a sweep
broke it in twelve places with the whole suite staying green: `insertBefore`
appending unconditionally, `insertBefore` not detaching (so a move becomes a
duplicate), `remove()` not removing, buttons no longer satisfying `instanceof
HTMLButtonElement`, `preventDefault()` doing nothing, `style.setProperty`
being a no-op.

Each of those makes a `mount()` test pass for the wrong reason. The
`instanceof` one is the sharpest: `main.ts` reads `disabled` only off
something that passes that check, so a fixture answering false makes every
disabled control look enabled -- and the guard that stops a double-send is
never exercised while its test still passes.

This package has now found FOUR fakes lying about the port they stood in for
-- `setTimer`'s delay, `every`'s period, `advance`'s amount, and a fake DOM
that appended instead of ordering. A fixture with twelve unguarded behaviours
is the fifth waiting to happen, so it gets its own tests rather than being
trusted because the tests using it pass.

Thirteen self-tests. Seven representative mutations confirmed to fail.

`FakeDom` also exposes `make(tag)` now, so a test builds an element directly
rather than reaching through `ownerDocument` -- which is typed `unknown`,
because the app only ever hands it back to the fake.

1057 tests on node 22.23 and 24.12. boundaries: 136 files, no violations.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 18:56:01 +01:00
Mervin Praison ae43cdd62e Merge pull request #4573 from MervinPraison/fix/mobile-contract-selfdefence
fix(mobile): the adapter contracts could be gutted silently, new ones included
2026-08-28 18:52:00 +01:00
MervinPraisonandClaude Opus 5 ace861d60b fix(mobile): the adapter contracts could be gutted silently, new ones included
A targeted re-measure asked whether the contracts I added last round actually
defend anything. They do not, and neither did the two that came before them.

I deleted `assert.ok(fired >= 2)` from the time contract -- the one assertion
that catches `setInterval` becoming `setTimeout`, which stops every polling
loop in the app after a single tick -- and the suite reported 1035 pass, 0
fail. Fourteen assertions across all four contracts could be deleted for free.
Removing an entire SECTION of the time contract simply reported one test fewer
and zero failures.

The cause is the meta-tests. `contracts.test.ts` has a set named "the contract
catches an adapter that ...", and each one builds a broken adapter and then
RE-IMPLEMENTS the contract's assertion inline. That proves an assertion of that
shape would catch the defect. It proves nothing about whether the contract
still contains it. They are decorative.

This is not a regression in the new contracts. The old storage and shell
contracts have the identical hole, and the whole pattern in that file is the
same shape as the defect this package keeps finding: a mechanism that exists,
is documented, is tested, and is connected to nothing.

The fix already existed one directory over. `engines/src/contract-fixture.ts`
spawns the REAL contract against an engine broken in one named way and asserts
the matching case goes red BY NAME -- written for exactly this reason, with a
comment saying so. The adapter contracts never got it.

`adapters/src/conformance/contract-fixture.ts` is that, for adapters. Six break
modes, each asserted to redden its own named case, plus a `none` control so a
fixture that failed for an unrelated reason -- a syntax error, a bad import --
could not masquerade as proof. It builds its broken adapters INLINE rather than
importing the fakes, because `adapters` may not import `testing` and the layer
rule is right to say so. The boundary checker caught my first attempt.

The break modes are chosen from real defects: two keychain accounts sharing one
credential; an empty string read back as absent; a missing key reading
`undefined`; namespaces colliding; `every()` firing once; a clear that does not
clear.

And a count floor, because the break modes prove a case still WORKS while a
case with no break mode could simply be deleted. It is counted from a real run
of the fixture rather than from source text -- a regex over `test(` would be
satisfied by a case that asserts nothing.

Verified: gutting a case, deleting a case, and deleting the load-bearing
assertion of a case all now fail. Deleting ONE of two redundant assertions
still passes, correctly -- the case retains its power through the other.

1043 tests on node 22.23 and 24.12. boundaries: 135 files, no violations.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 18:46:32 +01:00
Mervin Praison 64a209aaba Merge pull request #4572 from MervinPraison/fix/mobile-turn-lifetime
fix(mobile): every failure after the first turn was completely silent
2026-08-28 18:24:03 +01:00
MervinPraisonandClaude Opus 5 2afc146593 fix(mobile): every failure after the first turn was completely silent
Round five, driving realistic SEQUENCES through the real mount() rather than
one action at a time. Two critical defects, one root cause.

`turn` was a controller-lifetime variable. `runTurn` reset `approvals` -- added
last round for exactly this reason -- and never reset `turn`. The only thing
that reset it was a `start` event.

So a turn producing NO start met a state left `ended` by its predecessor:
`apply()` dropped the error as `wrong_msg_id` or `after_terminal`, and
`finish()` returned the PREVIOUS turn untouched because its outcome was
already set. And a turn produces no start for every pre-first-token failure --
401, 403, 500, offline, a refused connection, a wrong baseUrl.

Measured, turn 1 answers and turn 2 gets a 401:

  turn 2 text   : "first answer"      <- the PREVIOUS answer
  turn 2 outcome: {"type":"end", ...} <- the PREVIOUS outcome
  turn 2 dropped: 1

The user types a second question, taps Send, the composer clears, and NOTHING
HAPPENS. The old answer stays on screen. No error, no retry, no auth prompt,
not even a hint that a run was attempted. Silent, repeatable, permanent. The
default baseUrl is 127.0.0.1:8765 -- the phone itself -- so "engine
unreachable" is the common case here, not an exotic one.

Two amplifiers: the dropped error landed in `carry`, so the next SUCCESSFUL
turn inherited a "1 event could not be read" row blaming itself for its
predecessor; and `send()` publishes before `runTurn` starts, so a stale turn
was painted into what the user believed was a new conversation.

THE SECOND ONE IS THE SAME BUG WITH A WORSE PAYLOAD

New chat clears the screen. It could not clear the controller's `turn`, so the
wipe held only until the next Send -- at which point the PREVIOUS chat's
pending approval reappeared, with live buttons. `setChat` had already cleared
the approvals table, so the row found no entry, rendered `actionable: true`,
and Allow posted the decision to the engine.

The scenario is a user abandoning a conversation BECAUSE it asked to run
`rm -rf /`, starting a fresh chat, typing something unrelated, and being asked
again underneath it.

ALSO FIXED

A turn the user STOPPED rendered as a red "Connection lost" with a Retry
button, and announced "Connection lost" to a screen reader. The catch mapped
every thrown iterator to `transport` without asking whether the abort was
ours: Stop cancels the engine, aborts the reader, and a real fetch then errors
the body stream. The `cancelled` outcome and its "Stopped" notice were
reachable only if the engine's goodbye frame won a race against our own abort,
which it usually loses. Both directions are pinned -- a stream that dies on its
own is still a transport error, because hiding a real network failure behind a
calm "Stopped" removes the retry the user needs.

Every launch's FIRST conversation went to the engine as the literal
"unassigned". `chatId` defaults to it and `setChat` was only ever called by the
New chat handler. Against an engine keying server-side history by chat_id,
every user's first chat on every device was one shared thread. The existing
test asserted only that two chat ids DIFFER, and "unassigned" !== "chat-2"
passes that.

The suite passed 1029/1029 before any of this changed, which is the measure of
how little of it was observed: no test in the package ran a FAILING turn after
a SUCCEEDING one.

1035 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 18:20:25 +01:00
Mervin Praison 4b434adcaf Merge pull request #4571 from MervinPraison/fix/mobile-persisted-answer
test(mobile): nothing asserted what was written, only what was streamed
2026-08-28 18:00:19 +01:00
MervinPraisonandClaude Opus 5 a7cbf69460 test(mobile): nothing asserted what was WRITTEN, only what was streamed
The last of the sweep's three structural recommendations. Its own words: "items
2, 4 and 41 all hide behind the same blind spot -- the tests read the event
stream and never read back the file."

The blind spot was one line. `engine.test.ts`'s fake persistence is

    async record(req) { recorded.push(req); return indices; }

It records the request and THROWS THE ANSWER AWAY, so nothing in that file
could see what actually reaches the repository.

  answer = event.text -> +=

survived. `finish` carries the full text and the engine trusts it over the
streamed accumulation, because upstream may normalise and a non-streaming
provider finishes with content having emitted no deltas at all. Appending
instead writes the answer TWICE to disk -- while the screen, built from the
deltas, looks perfectly correct. The user sees a normal answer, and finds it
doubled when they reopen the chat.

Four tests now assert the persisted side, covering both directions: a
normalising finish must win over what was streamed, and a stream with no finish
must still persist what it streamed. Deleting the finish handling entirely
fails four of them.

Same blind spot, second instance:

  updated: at -> updated: base.updated

survived, because no test read a chat back across TWO turns. The chat keeps the
timestamp of its first message forever, so a list ordered by `updated` sinks
the conversation you are actively using to the bottom while one you have not
touched in a month sits at the top.

1029 tests. Tests only; no product source changed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 17:56:58 +01:00
Mervin Praison 4d1328dc88 Merge pull request #4570 from MervinPraison/fix/mobile-time-contract
test(mobile): the TimePort had no test and no contract, and scored 3 of 3 surviving
2026-08-28 17:53:33 +01:00
MervinPraisonandClaude Opus 5 54d5181bb3 test(mobile): the TimePort had no test and no contract, and scored 3 of 3 surviving
The worst-scoring module in a 226-mutation sweep, and the last of that sweep's
three structural recommendations. `adapters/src/web/time.ts` had no test file
and no contract; every mutation aimed at it survived. What each one shipped:

  setInterval -> setTimeout    EVERY polling loop in the app fires once and
                               stops. Readiness polling, the coalescer's flush
                               tick, elapsed-time updates -- all of it, and the
                               app reads as merely slow rather than broken.
  clearTimer does nothing      A cancelled timer still fires, so the publish
                               gate reopens after it was deliberately shut.
  performance.now -> Date.now  Elapsed measurements jump backwards when the
                               clock is NTP-corrected mid-turn, which is
                               exactly why the port separates the two.
  unsubscribe does nothing     The interval leaks for the app's lifetime.

Written as a contract because the fake and the real adapter must agree, and
because this port has form: `every`'s period and `setTimer`'s delay were BOTH
found discarded in the fake, in two separate rounds. A test that only ever
drives the fake cannot see it a third time.

Two things the contract had to be honest about rather than paper over:

The fake deliberately uses ONE clock for nowMs and epochMs -- its own header
says so, "safe in a fake and wrong in an adapter". Asserting they differ would
be asserting the fake is broken, so that case is guarded and runs only against
a real-time adapter, where it is the case that matters.

The web adapter targets a browser, and `requestFrame` throws ReferenceError in
Node. The contract supplies requestAnimationFrame rather than skipping the
case. Not having that shim is part of why this adapter had never been
contract-tested at all.

All four mutations now fail by ASSERTION, not merely by timing out -- checked,
because the unsubscribe one also hangs the run (the leaked interval keeps the
event loop alive, which is the resource leak it ships), and a hang is not the
same as a test that says what is wrong.

1024 tests on node 22.23 and 24.12. Tests only; no product source changed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 17:50:17 +01:00
Mervin Praison 1856aa71b5 Merge pull request #4569 from MervinPraison/fix/mobile-round-four
test(mobile): the SecretsPort had no test, and the fallback paths never ran
2026-08-28 17:34:01 +01:00
MervinPraisonandClaude Opus 5 6107d70cbb test(mobile): the fallback paths never ran, so every mutation in them survived
The sweep's structural finding, and the reason its survivor count did not fall:
almost every genuine survivor lives on a FALLBACK path -- the branch that runs
when the platform is missing something. The suite runs on a Node with full ICU
and no DOM, so the primary path is exercised hard and the degraded path is
never entered at all.

That is one gap, not many independent ones.

`direction()` is `directionFromIntl(tag) ?? directionFromTables(tag)`. On the
test host the first half always answers, so the second half -- the branch that
runs on an older Android WebView without `Intl.Locale.textInfo` -- had FIVE
indistinguishable mutations in it. What each shipped:

  RTL_LANGUAGES never matches    Arabic, Hebrew, Farsi and Urdu render LTR
  script not title-cased         az-Arab and ku-Arab render LTR
  length test 4 -> 3             ar-Latn and ks-Deva render RTL

The whole UI mirrored the wrong way, or not at all, on exactly the devices most
likely to reach this code.

`sentences()` uses `Intl.Segmenter` whenever it exists, and it always does
here, so `fallbackSentences` never ran. Three mutations survived in it -- one
per failure the file's own header names: splitting on a decimal point,
splitting mid-CJK, and failing to split after a quoted full stop. A fourth
dropped the unterminated tail, which on a host without Segmenter means a blind
user never hears the in-progress end of an answer, and breaks the documented
seam guarantee that slice(0,n) + slice(n) recombine.

Both are now exported and called directly, which is this repo's stated rule --
extract the expression so the test calls it rather than asserting it appears in
the source. Stubbing globals would have worked too; exporting is honest about
what is being tested and does not leave a fixture that can itself drift.

1009 tests on node 22.23 and 24.12. Four representative mutations confirmed to
fail. Product source changed only by two `export` keywords and their comments.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 17:30:35 +01:00
MervinPraison 52f43d7f4d docs: auto-update documentation parity trackers [skip ci] 2026-08-28 16:29:19 +00:00
Mervin Praison 8b837126f3 Merge pull request #4568 from MervinPraison/claude/issue-4567-20260828-1621
fix: update VideoAgent Veo IDs to 3.1 family, retire deprecated Veo 2/3.0
2026-08-28 17:28:42 +01:00
MervinPraisonandClaude Opus 5 7f7e6e1b33 test(mobile): the SecretsPort had no test of any kind, and six more tier-1 gaps
A 226-mutation sweep against an isolated worktree. 59 genuine survivors, 10
equivalent mutants each proved by differential test rather than asserted. These
are the seven with the sharpest user consequence; the sweep's own structural
recommendation is the next commit.

TWO ACCOUNTS IN ONE SLOT SHARED ONE CREDENTIAL

`adapters/src/web/secrets.ts` had no test file. Collapsing its key from
`${slot}:${account}` to `${slot}` survived everything: adding a second
profile's API key silently overwrites the first, reading either returns
whichever was written last, `has()` answers true for an account never set, and
deleting one deletes both.

Written as a CONTRACT rather than a test of one adapter, matching shell and
storage. The fake and the web adapter must agree on key semantics or a test
that passes against the fake ships a device-only defect. That is not a guess:
in the same sweep the two ports WITH a contract scored best and the two
without scored worst. The mutation now fails three times, once per adapter.

A DISABLED CONTROL STILL FIRED AN ACTION

`if (el.disabled === true) return null` -> `continue`. The walk skips the
disabled element and keeps climbing, so a tap on a greyed-out approval button
reaches the enclosing row and fires THAT row's intent. It is the only thing
between a double tap and a second send, and between a tap on a decision already
in flight and a second decision.

ONE CORRUPT CHAT FILE HID EVERY CHAT AFTER IT

`if (!result.ok) continue` -> `break`. Half the library vanishes with no error
anywhere, and `listUnreadable` still reports only the one bad file -- so the
app is confidently wrong about how much is missing.

A CHAT WITH NO id LOADED SUCCESSFULLY

Dropping `typeof chat.id !== "string"` gives `ok: true` with `id: undefined`: a
row addressed at nothing, absent from `listUnreadable`, and saved back to the
storage key "chats/undefined". The header's rule -- "a chat that fails to parse
is reported, not skipped" -- broken in the one direction nothing checked.

A REFUSED DECISION COULD BE MARKED SENT

`acknowledge` accepting a `failed` entry flips `isActionable` to false and
drops `outstanding` to zero: the buttons die and the run stays blocked on a
prompt the engine never received, until its 300-second timeout. Verbatim the
desktop failure approvals.ts says it exists to prevent.

AN UNMOUNTED SCREEN WAS NEVER FORGOTTEN

Dropping `nodes.delete(id)` makes `live()` lie. screens.ts asks `live.has(next)`
to decide whether to mount, gets a stale yes, and never rebuilds -- a blank
screen with no way back. Its sibling, dropping the `nodes.has` guard, appends a
second copy and orphans the first.

Also covered: a chat id that vanishes between `listIds` and `read` is absent,
not corrupt -- reporting it unreadable shows a permanent corruption warning for
a file that was simply deleted.

999 tests on node 22.23 and 24.12. Tests only; no product source changed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 17:28:15 +01:00
praisonai-triage-agent[bot]andMervinPraison 74256c4440 fix: update VideoAgent Veo IDs to 3.1 family, retire deprecated Veo 2/3.0 (fixes #4567)
- Docstrings list Veo 3.1 Standard/Fast/Lite (Gemini preview + Vertex GA IDs)
- Change examples from veo-3.0-generate-preview to veo-3.1-generate-preview
- Mark Veo 2.0 / 3.0 IDs as deprecated (not recommended)
- Document Veo 3.1 interpolation (last_frame=, reference_images=) via existing
  **kwargs passthrough instead of adding new params (keeps surface lightweight)
- Brief auth + pricing note (API USD/sec vs Flow credits)
- Update tests and example to 3.1 IDs

Co-authored-by: MervinPraison <[email protected]>
2026-08-28 16:24:02 +00:00
Mervin Praison 597d998187 Merge pull request #4565 from MervinPraison/fix/mobile-composition-root
fix(mobile): six defects in the composition root, and the harness that finds them
2026-08-28 17:22:58 +01:00
MervinPraisonandClaude Opus 5 096d8303bd test(mobile): the refused-stop retry had no test, and the first one I wrote passed by accident
Mutating the refused-stop fix back was `fail=0`: I had verified it with a
throwaway script and never added a test. Worth recording, because the fix
itself was a regression of my own from two commits earlier -- so the defect had
now escaped twice, once into the code and once past my own verification.

The first pair I wrote was also wrong. `SCRIPTS.happy` finishes on its own, so
whether a run was still live when Stop was pressed depended on microtask
ordering: the refusal test passed because `stop()` returned false for the
BORING reason -- nothing was running -- rather than because the engine refused.
Its sibling then failed and exposed it.

Both now use an engine whose turn stays open until the test releases it, so a
run is genuinely live when Stop is pressed. Three mutations die:

  refused stop aborts anyway        fail=1
  refused stop keeps `cancelling`   fail=1
  bootOrFail rethrows               fail=1

971 tests on node 22.23 and 24.12.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 17:19:13 +01:00
MervinPraisonandClaude Opus 5 2768bf6f02 fix(mobile): six defects in the composition root, and the harness that finds them
A round-four audit drove the real `mount()` through a fake DOM for the first
time. Every defect it found is the same shape: a pure function that is correct,
and a composition root that calls it wrongly or not at all. main.test.ts was
30 lines and said so out loud -- "`mount` needs a whole fake SSE transport to
drive end to end, so the parts of it that can be wrong are extracted and called
directly instead". Every one of these lived in the part that was not extracted.

1. EVERY PRE-FIRST-TOKEN FAILURE SAID "the engine produced no output"

`apply()` drops any event arriving before `start`, and both the controller's
synthesised transport error and the engine's synthesised auth error ARE the
first event. So the real reason was thrown away, `finish()` substituted
kind: "empty", and 401, 403, 500, 502, offline, DNS and TLS all rendered
identically -- plus a dropped row accusing the engine of sending an event
before the turn began.

The default baseUrl is 127.0.0.1:8765, which on a phone is the phone itself, so
this was the FIRST thing every new user saw. It also made the auth ->
"go to settings" recovery unreachable: the one distinction between "retry this"
and "fix your credentials" never survived to the view model.

An error is a terminal event; if nothing has started, it IS the turn's outcome.

2. A SCREEN-READER USER NEVER HEARD THE ANSWER

`for (const item of announcements) region.textContent = item.text` overwrites
the earlier items in the same task. A short answer completing inside one
interval produced [polite "<the answer>", polite "Response complete"], so the
user heard only "Response complete." That is verbatim what announce.ts rule 4
exists to prevent -- "the user would never hear the end of the response" --
reintroduced at the single point where the pure function meets the DOM.

3. NEW CHAT DID NOT STOP THE RUN

It cleared the screen and left the turn streaming, so the next token
reconciled against an empty render and re-inserted the previous conversation's
rows into what the user believed was a fresh chat, where it finished.

4. EVERY REQUEST FROM EVERY CHAT CARRIED chat_id "unassigned"

`setChat` was never called anywhere in the app. controller.ts says in as many
words that "an engine cannot tell two conversations apart if every turn claims
the same id"; against an engine keying server-side history by chat_id, every
conversation the user ever had was one thread.

5. A STORAGE FAILURE AT BOOT LEFT A RENDERED, DEAD APP

`createApp` returns a typed BootResult for anticipated failures and THROWS for
a StoragePort failure -- reachable via SecurityError with site data blocked or
QuotaExceededError. The chrome is appended before that await, so the rejection
skipped both the fatal screen and the listener registrations: top bar,
composer, green Send button, and nothing ever happened again. No spinner, no
error, no crash screen.

6. THE CRASH HANDLER WAS INSTALLED AFTER THE THING THAT THROWS

`detectPlatform()` ran first, directly contradicting the comment above the
crash handler. It reads window.localStorage, which raises SecurityError with
site data blocked, and a throw there left the root completely empty.

AND ONE REGRESSION OF MY OWN

`stop()`'s idempotence guard, added two commits ago, aborted the reader even
when the engine ANSWERED FALSE. So a refused stop set `aborted`, and the next
tap returned true without asking the engine again. The user is told the stop
was refused, taps the only affordance offered, and is told nothing at all --
which stopNotice defines as success -- while the run keeps generating. The
defect that notice exists for, inverted, one layer down. A refusal now leaves
the run retryable and the reader attached.

THE HARNESS

testing/src/fake-dom.ts models the DOM behaviours the app actually depends on:
ordering under insertBefore (which DETACHES first, the detail that hid two
ordering defects here), textContent replacing children, delegated event
dispatch, and the three constructors main.ts walks with `instanceof` -- without
which the click listener throws `Element is not defined` and every intent is
lost. That is why nobody had driven the composition root before: the first
attempt fails before reaching an assertion.

Eight tests now drive mount() end to end. Each fix was confirmed by mutating it
back and watching a named test fail.

969 tests. boundaries: 132 files, no violations.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 17:16:53 +01:00
Mervin Praison 3533375d01 Merge pull request #4564 from MervinPraison/docs/mobile-gate-reachability
docs(mobile): the publish gate is wired and never runs
2026-08-28 16:56:16 +01:00
MervinPraisonandClaude Opus 5 cc424fe902 docs(mobile): the publish gate is wired and never runs
`publish-gate.ts` is a verbatim port of the desktop's stream-pacing with two
constants tightened deliberately for mobile. It IS called -- controller.ts has
`if (frames.length > 0 && gate(streamed)) publish()` -- but in the shipped
pipeline that condition is almost never true, so neither constant affects
anything.

The cause is the coalescer's flush tick, added three PRs ago to fix answers
arriving in one lump. The tick drains the coalescer every maxDelayMs, so by the
time a delta arrives `push()` usually returns [] and the gate is never
consulted. The tick's own publish is deliberately ungated, which is right --
that frame exists precisely because nothing has painted recently.

Measured by driving the real controller with a virtual clock and counting
whether the gate was consulted even once. The count is exact rather than
inferred: the gate calls requestFrame on its FIRST invocation, so zero frame
requests proves zero calls.

     20 tok/s   2004 publishes for 2000 tokens   gate consulted: no
     60 tok/s   2004                             no
    150 tok/s    670                             no
    400 tok/s    289                             no
   1600 tok/s     80                             no
   3200 tok/s     42                             YES
   8000 tok/s     42                             YES

The threshold is where 256 bytes land inside one 16ms window. Real model
streaming is 20-150 tok/s, so it is unreachable in practice.

Not currently a defect: paints stay bounded by time, which is the property that
matters, and per-publish view work is under 9% of a frame in the worst measured
case. Recorded because two things follow. There is no backpressure path at all
-- the gate is the mechanism that notices the renderer cannot keep up, and it
never runs. And a module with its own tuned constants that never executes reads
as load-bearing to whoever next touches pacing.

The fix is to wire the tick's publish through the gate or delete the module.
Deciding that is a design call, not a bug fix, so it is written down rather
than done quietly. What is not acceptable is leaving it LOOKING wired, which is
the defect class this package keeps producing.

Docs only.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 16:53:14 +01:00
Mervin Praison 28732cc71e Merge pull request #4563 from MervinPraison/fix/mobile-round-three-tail
test(mobile): seven mutations could be applied at once and the suite stayed green
2026-08-28 16:50:52 +01:00
MervinPraisonandClaude Opus 5 293517fc48 test(mobile): seven mutations could be applied AT ONCE and the suite stayed green
The documented tail of round three's 49 survivors. I confirmed all seven were
still live by mutating them and, through a mistake in my restore paths, ended
up applying every one simultaneously -- 957 tests still passed with all seven
defects in the tree at the same time. That is a better demonstration than the
one I intended, so it is worth recording rather than tidying away.

  count: turn.dropped.length -> count: 1
      The dropped row always said "1". `reasons: []` was caught; the count was
      not. The reason the row carries a number at all is to distinguish one
      malformed frame from a stream that is mostly unparseable.

  copy: turn.text !== "" -> copy: true
      A Copy button on an empty answer copies an empty string and reports
      success -- an action that says it did something and did not.

  signatureOf drops ${row.recovery}
      The comment two lines above it says exactly why it is there: "the same
      message can offer a different action once the engine reports a different
      kind". Nothing checked. The row would keep a Retry button after the
      engine reclassified the failure as one retrying cannot fix.

  signatureOf drops ${row.reasons.join(",")}
      Two refusals for different reasons look identical to a count, so the row
      keeps naming the wrong failure.

  pending.length >= maxBytes -> >
      Off by one on the constant the coalescer's whole contract is written
      around. No test ever landed exactly on the cap.

  formatElapsed `seconds < 10` -> `<= 10`, `minutes < 60` -> `<= 60`
      The entire point of a threshold is which side of it a value falls on,
      and no test placed a value exactly on one. 60 seconds rendered "60s"
      instead of "1m 00s"; 3600 rendered "60m 00s" instead of "1h 00m".

Each is now pinned in both directions -- an unchanged row still produces no
ops, so a signature that changed every time cannot pass by repainting always.

964 tests on node 22.23 and 24.12. Tests only; no product source changed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 16:41:35 +01:00
Mervin Praison 9ade11888f Merge pull request #4562 from MervinPraison/fix/mobile-decode-coverage
fix(mobile): the reconciler's coordinate systems, and decode.ts's 15 surviving mutations
2026-08-28 16:37:03 +01:00
praisonai-triage-agent[bot]andMervin Praison aeabc151b4 test(mobile): close the reconcile->DOM loop the fix's own claims rely on
The reconciler fix landed, but the harness the PR body describes as proving
it -- a fake DOM with real insertBefore reference-node semantics, and a
reconcile->applyOps ordering sweep -- was never committed. dom.test.ts's fake
still appended unconditionally (a fake that cannot represent order cannot fail
an ordering test), and reconcile.test.ts asserts op shapes without applying
them.

Give the fake real insertBefore/remove semantics and add the exhaustive
prev/target sweep in app/ (the only layer allowed to import both halves),
applying the real ops through the real applyOps and asserting DOM order equals
the target. Verified both ways: reverting the reconciler to the surviving-index
model, or the fake back to append-only, makes the sweep fail. Also asserts the
minimal-op guarantees at the DOM (append is one insert; a settled list is zero
ops).

957 tests pass; boundaries clean; no new typecheck errors.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 15:26:52 +00:00
MervinPraisonandClaude Opus 5 39679b55e6 fix(mobile): the reconciler modelled its moves in the wrong coordinate system
`reconcile` tracked `surviving` -- the previous ids minus removals -- and
spliced each move into it at the TARGET index. Those are two different
coordinate systems: the target index counts rows inserted on this pass, and
`surviving` contains none of them. Inserted rows were never added to it either,
so the model drifted from the DOM as soon as one insert preceded a reorder, and
the emitted ops then described a list nobody had.

Measured by applying the REAL ops through the REAL applyOps to a DOM:

  exhaustive, 5 ids, every prev/target pair    120 of 960 rendered wrong
  fuzz, 8 ids, 200k pairs                      1546 wrong
  smallest case  [a,b,c] -> [d,e,b,c,a]        rendered [d,e,b,a,c]

It now models the list it is actually producing: `current` starts as the
survivors, every insert and move is applied to it, and a row already in place
emits nothing. Both sweeps are 0 wrong. The minimal-op guarantees are intact --
appending to 200 rows is still one insert, an unchanged list still zero ops --
and those are now asserted here too, so a correct-but-rebuilding reconciler
cannot pass by brute force.

WHY NOTHING CAUGHT IT

Two reasons, and both are worse than the bug.

`reconcile.test.ts` asserts the SHAPE of the ops and never applies them. It can
say "a move was emitted for c at index 0"; it cannot say whether running the
ops produces the intended order. The pair is tested here in app/, the only
layer allowed to import both halves.

And the fake DOM's `insertBefore` ignored its reference node and appended
unconditionally. A fake that cannot represent ORDER cannot fail an ordering
test. It now has real semantics, including detaching the node first -- and
mutating it back to appending makes the new sweep fail, so the fake is itself
under test.

That is the fourth fake in this package found lying about the port it stands
in for, after `setTimer`'s delay, `every`'s period and `advance`'s amount.

WHAT I DID NOT KEEP

I first "fixed" this in applyOps by detaching the node before placing it. With
the reconciler corrected, that mutation is `fail=0` -- no test can tell the
difference, because reading `children[op.index]` before `insertBefore` detaches
already names a real sibling. So it went back out. Unverified code that claims
to fix something is the thing this session keeps finding; adding more of it
while removing a bug is not a trade worth making. dom.ts keeps only a comment
saying why the existing line is correct.

Reachability today is low: transcript rows are append-only, so the wrong-order
path needs an insert before a reorder, which the current view model does not
produce. This is a latent defect in a core renderer, fixed before the screens
that would reach it are built.

952 tests on node 22.23 and 24.12.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 16:19:26 +01:00
MervinPraisonandClaude Opus 5 0d8a312e87 test(mobile): decode.ts had 16 tests and 15 surviving mutations
A mutation sweep over the whole package found this file the weakest in it:
16 tests, and 15 of 16 mutations survived. The existing tests cover the SHAPE
of every event thoroughly -- and never once pass `versions` other than 1,
`active` other than 0, an absent `assistant_index`, or an empty-string id.
Those are the fields that decide which message the UI selects and which turn
Fork and Delete act on.

Nine mutations now fail. Each was applied and measured first:

  `: userIndex + 1` -> `: userIndex`      the assistant index points at the
                                          USER'S OWN message, so anything
                                          selecting by it renders the prompt
                                          back as the answer
  versions clamp removed                  `versions: 0` reaches the UI -- a
                                          message existing in zero versions
  active floor / ceiling / off-by-one     events.ts says an out-of-range index
                                          "selects nothing and the message
                                          renders blank"; this clamp is the
                                          only thing between a buggy engine and
                                          an empty answer bubble
  `active ?? 0` -> `?? 1`                 silently selects the SECOND version
                                          of every message the engine gave no
                                          cursor for. Invisible with one
                                          version, which is why it survived
  finite check removed                    NaN / Infinity accepted for versions,
                                          active, chars and seconds
  `!(key in o)` -> `=== undefined`        collapses absent vs present-undefined,
                                          the exact distinction this file's own
                                          comment says it exists to prevent
  array accepted as tool args             every consumer gets an object keyed
                                          "0","1",... -- and args are shown to
                                          the user as the command being approved
  `|| raw === "always"`                   "always allow" decodes to null, so the
                                          button exists, is pressed, and nothing
                                          happens

Two of my first attempts asserted my guess rather than the contract and failed:
present-but-undefined resolves to `null` (not on disk), not to a derived index;
and `approval_response` is not an event -- the choice decoder is a standalone
export. Both tests now assert what the code actually guarantees, which is the
better behaviour in each case.

`versions ?? 1` -> `?? 0` is genuinely EQUIVALENT: the Math.max(1, ...) below
it maps both to the same output. Recorded in the test file so nobody hunts it
as a live survivor.

952 tests. Tests only; no product source changed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 16:13:47 +01:00
Mervin Praison bc8c76abee Merge pull request #4561 from MervinPraison/fix/mobile-approvals-scope
fix(mobile): a CRLF stream lost the answer, and Stop could kill the wrong run
2026-08-28 16:09:56 +01:00
praisonai-triage-agent[bot]andMervin Praison e03dfdeb06 fix(mobile): an empty chunk dropped the pending CR, and overlapping stops lied
Two defects the final review found in PR #4561's own changes:

- sse.ts recomputed the swallow-CR flag from every chunk, including an
  EMPTY one. `"".endsWith("\r")` read false, cleared the flag, and a `\n`
  in a later chunk survived as a false frame boundary -- splitting one
  CRLF frame into two malformed ones. A chunk with no decoded characters
  now leaves the pending-CR state untouched (and clears it only when the
  swallow consumed the LF). Regression test added.

- controller.stop()'s `aborted` guard is sequential: two OVERLAPPING
  stops (dispose + the Stop button) both pass it before either
  engine.cancel resolves, each issues its own cancel, and an honest
  engine refuses the duplicate -- reporting a real stop as rejected. The
  in-flight cancel is now memoised per run so overlapping stops share one
  result. Regression test forces the overlap.

Both new tests were mutation-verified (each fails against the un-fixed
line). npm run check: typecheck clean, boundaries 131 files no
violations, 939 tests pass on node 22.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 15:00:15 +00:00
MervinPraisonandClaude Opus 5 488cab2c38 fix(mobile): a CRLF stream lost the answer, and Stop could kill the wrong run
Round three of multi-agent validation, run against an isolated worktree so
nothing could interfere. It found what the previous two did not, including two
defects that lose or corrupt user-visible work.

1. AN SSE STREAM WITH CRLF LINE ENDINGS SILENTLY LOST CONTENT

`sse.ts` normalised line endings PER CHUNK. A chunk ending in `\r` became
`\n`, and the next chunk's leading `\n` then completed a `\n\n` that was never
a frame boundary -- one frame became two malformed ones.

Measured end to end through the real engine over a CRLF stream:

  chunk size 1024  ->  "The answer is 42."      correct
  chunk size    7  ->  "The answer "            half the answer gone
  chunk size  1-3  ->  ""  + error "the engine produced no output"

Seven bytes is the repo's own test chunk default. CRLF is not exotic: the spec
permits it and any proxy may rewrite to it. A transport bug was rendering as
the model having said nothing.

The first fix held a trailing `\r` back until the next chunk could say whether
it was half of a `\r\n`. That regressed the OTHER legal ending -- a lone-CR
stream ends on `\r` with no chunk after it, so its last frame never completed,
taking CR-only streams from working to silent. Caught by testing all three
terminators rather than only the broken one. The `\r` is now emitted
immediately and a matching `\n` swallowed if one follows.

Verified exhaustively: every one-cut AND two-cut split of a two-frame stream,
for CRLF, LF and CR -- 0 wrong of 5253, 4656 and 4656 -- plus byte-by-byte
delivery for each. The file had a CRLF test and chunk-split tests; it never
had both at once, and the defect lived at exactly one offset out of 103.

2. STOP COULD CRASH THE APP OR CANCEL THE WRONG RUN

`stop()` captured `runId` but `live.abort.abort()` re-read the closure variable
AFTER awaiting the engine -- and cancel is a real network call, so the turn can
settle inside that window:

  queue empty     `live` is null, TypeError. Both callers reach stop() through
                  a floating `void`, so the crash handler replaced the whole
                  app with the crash screen. Tapping Stop at the wrong instant
                  blanked the page.
  queue non-empty `live` is the QUEUED FOLLOW-UP. The engine was asked to
                  cancel the finished run while the abort killed the new one,
                  which rendered "the engine produced no output" with Retry --
                  and stop() returned true, so the UI announced success.
                  Cancelling "whatever is live" is what runId exists to stop.

The run is captured, not just its id. stop() is also idempotent now: a second
tap re-issued cancel for an already-cancelled run, every honest engine answers
false for that, and stopNotice then announced "the engine did not accept the
stop" for a stop that DID happen -- inverting the defect that notice was added
for two commits ago. The background -> dispose path calls stop() twice by
design.

My first test for this did not reproduce the race at all: the turn had already
settled before stop() ran, so `live?.abort.abort()` survived it. The test now
forces the ordering -- cancel() releases the first turn and waits until the
follow-up has genuinely started -- and the mutation dies.

ALSO CLOSED, each mutation-verified:

- `drainDrops` accumulated into `next`; restarting from `state` each iteration
  kept only the LAST refusal of a batch. Every existing test drained exactly
  one at a time. The channel exists to make "40% of this stream is
  unparseable" visible; keeping one of every N divides that by N.
- `decide()`'s catch could `acknowledge` instead of `reject` and survive: an
  approval whose engine call THREW rendered as sent and accepted. The
  `ok === false` path was tested; the throw path was not.
- `set()` did not mark a key chosen -- only `load()` did -- so a setting the
  user changes IN THE APP was ignored by `chosenStringOr` and the caller's
  default won. That is precisely the defect `isSet` was added to prevent,
  reachable through the commoner of its two paths.
- An empty-string `engineId`, trivially persisted by a settings field, outrank-
  ed the composition root's default and boot died with `unknown_engine ''`.
- HTTP 403 was classified `transport`, not `auth`, so the UI offered Retry
  forever instead of sending the user to credentials.
- `createCoalescer(maxBytes, maxDelayMs)` takes two numbers, so transposing
  them typechecked and survived: frames would flush at 16 BYTES with a 256ms
  delay bound. Same-typed adjacent arguments, the shape that has already
  produced one defect here.
- `FakeTime.advance(ms)` could ignore `ms` entirely and leave all 915 tests
  green, making every pacing constant reached through it untested. Its tick
  period was not enforced either, contradicting the fake's own comment. This
  is the THIRD time a fake in this package has been found discarding an
  argument its port declares, so the fake now has its own tests rather than
  being trusted because the tests using it pass.

937 tests on node 22.23 and 24.12. boundaries: 131 files, no violations.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 15:51:42 +01:00
Mervin Praison 671bfdc1db Merge pull request #4560 from MervinPraison/fix/mobile-decode-drops
fix(mobile): a malformed frame made a tool vanish and the turn looked clean
2026-08-28 15:25:18 +01:00
MervinPraisonandClaude Opus 5 e6349be3a7 fix(mobile): a refusal in the gap between turns belonged to neither
The triage bot fixed the same leak independently and pushed to this branch
while I was fixing it. Both stop the leak; they disagree about one case, and
the disagreement is real.

  bot:  dropped: state.phase === "ended" ? [] : state.dropped
  mine: dropped: state.carry   (drops recorded while nothing was streaming)

The bot's discards the previous turn's list wholesale once it has ended, so a
frame the DECODER refused in the gap between two turns goes with it. Such a
refusal often has no msgId -- that is frequently why it was refused -- so it
cannot be attributed backwards to the turn that just finished. It belongs to
the turn about to open, and losing it quietly is the exact failure this whole
channel exists to undo.

Its test found the case mine had wrong, though: a LATE FRAME FROM THE OLD RUN
(`after_terminal`) is evidence against the old run, and carrying it forward
blames the next answer for its predecessor's mess. `carry` now takes
everything recorded while not streaming EXCEPT `after_terminal`, which is the
one out-of-band drop that is genuinely about the turn just finished.

My own between-turns test did not distinguish the two implementations: it
started from `initialTurn`, so the phase was `idle` rather than `ended`, and
the bot's version passed it. Mutating this file to the phase-only form was
`fail=0` until a test was added that ends a turn FIRST and then refuses a
frame. Another instance of a test observing an adjacent property rather than
the one that matters -- caught only by running the alternative implementation
against the suite.

Both tests are kept, and both implementations now fail:

  carry ignores the after_terminal split   fail=1
  the phase-only fix instead               fail=1
  carry everything, even mid-turn          fail=4

915 tests on node 22.23 and 24.12.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 15:15:37 +01:00
MervinPraisonandClaude Opus 5 807bf6e60c fix(mobile): drops leaked onto every later turn, and every parse failure lied
A second validation pass over the refusal channel this PR adds. The channel
works -- three previously-invisible refusals now reach the transcript -- but
making them reachable exposed two defects that were harmless while nothing
could produce one.

1. EVERY DROP LEAKED ONTO EVERY SUBSEQUENT TURN, PERMANENTLY

`apply`'s `start` case carried `state.dropped`, which at that moment is the
PREVIOUS TURN'S ENTIRE LIST. Its comment justifies carrying pre-start drops --
correct intent, wrong list. Nothing resets the controller's `turn`, so one
refusal on turn 1 made every later turn report itself damaged for the lifetime
of the app. Measured: turn 1 has one genuine refusal; turn 2 is a perfectly
clean stream and still renders it. `new-chat` does not help -- it resets the
renderer, not the controller.

That also made the count meaningless for the thing it exists to express, "this
stream is 40% unparseable", and removed the per-turn bound on an already
quadratic append (`[...state.dropped, x]`): a bad connection refusing 500
frames/s for a minute is ~2s of main-thread work on a phone, and every later
turn then pays for the inherited list on every publish.

`TurnState.carry` now holds only drops recorded while nothing was streaming,
and `start` takes exactly those. Four tests, both directions: a clean turn
after a damaged one shows nothing; a drop BETWEEN turns still reaches the next
one; it is carried once rather than forever; and a drop during a live turn
never enters the carry.

2. FIVE DISTINCT WIRE FAILURES ALL REPORTED THE SAME WRONG REASON

`safeParse` returned `{}` for anything it could not read, and the caller
spreads it and adds `type` -- so an HTML error page from a proxy, a body
truncated by a cut connection, a JSON array, a bare string and `null` all
arrived at the decoder as an object with only a type, and all came back as
`missing_msg_id`. The payload was discarded too, so `detail` could not recover
it. `decodeEvent` gets every one of these right when handed the raw string.

Survivable while rejections were discarded. They are user-visible prose now:
a 502 page from a proxy told the user "an event arrived with no message it
belongs to", pointing whoever they reported it to at an engine msg_id bug that
does not exist. Two shipped strings -- "not valid JSON" and "a value where an
event was expected" -- were unreachable for the same reason.

`parseFrame` returns a discriminated result, so the reason survives and the
detail carries enough of the frame to identify it. One test covers all five.

ALSO, from the same pass:

- `engineId.choices` offered `praisonai-ts`, which `enginesFor` only builds
  when `createInProcess` is supplied and main.ts never supplies. Selecting it
  persisted an id `selectEngine` then rejects, so the NEXT launch died at
  renderFatal with no way back except editing storage by hand. The list is now
  what the shipping composition can build, and its test compares against
  `enginesFor` rather than against the same two constants the def names --
  which is why it could not fail before.

- `adapters/src/web/secrets.ts` claimed "the composition root refuses to hand a
  hardware-backed secret to an engine when the transport is not native, and
  this flag is half of that check". There is no such check: the flag is read
  only by the settings view's warning row, and boot.ts has never consulted it.
  Nor is one needed yet -- no secret reaches an engine at all, because
  registry.ts never passes the `token` the engine accepts. The comment now says
  that, and says where the refusal belongs when the key is wired. Describing a
  guard that does not exist is worse than having none: it stops the next reader
  looking for one.

- Four settings are declared and consumed by nothing (`model`, `temperature`,
  `showReasoning`, `showDiagnostics`). Expected -- their screens do not exist --
  and now recorded in gaps.md so nobody concludes they work. `showDiagnostics`
  is called out in place because unlike the others it contradicts behaviour
  that already exists: the dropped row is rendered unconditionally.

912 tests on node 22.23 and 24.12. Every fix mutation-verified in both
directions.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 15:13:52 +01:00
praisonai-triage-agent[bot]andMervin Praison d9beed6f78 fix(mobile): a dropped frame on one turn haunted the next clean one
apply(start) carried `dropped` across every new start, including one that
followed a FINISHED turn -- so a refusal on turn 1 painted turn 2's clean
answer as damaged. A turn that already ended owns its drops; only a
before_start drop (recorded while idle) belongs to the turn now opening.
Clear `dropped` on a start that replaces an ended turn. Pinned at both hops:
reverting fails one reducer test and one controller test.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 14:05:28 +00:00
MervinPraisonandClaude Opus 5 f41a1fc6a4 docs(mobile): the decode-rejection defect is closed
gaps.md recorded it as knowingly open because closing it needed a channel from
the engine to the transcript that did not exist. It exists now, and the entry
says so rather than leaving a reader to discover the file is stale.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 14:57:44 +01:00
MervinPraisonandClaude Opus 5 414a39f529 fix(mobile): a malformed frame made a tool vanish and the turn looked clean
The last defect from the validation pass, and the one left open when #4558
merged because closing it needed a channel that did not exist.

remote-http is the ONLY production caller of decodeEvent, and it did:

    const outcome = decodeEvent(...);
    if (isDecoded(outcome)) yield outcome.event;

Every rejection went on the floor. So a `tool_result` frame missing its `ok`
-- and `ok` is the only signal of tool success, never inferred from output --
made its tool disappear entirely and the turn rendered as a clean answer with
a shorter reply. Meanwhile `Dropped.reason` already admitted every
IgnoredReason, the view model already rendered a dropped row, there was
already a `showDiagnostics` setting labelled "Show dropped events", and
ui/src/i18n/strings.ts already had a sentence for all seven reasons. None of
it was reachable, because nothing upstream ever produced one.

THE CHANNEL. The engine is built at composition and the controller per app, so
neither can call the other. `core/src/run/drop-sink.ts` sits between them:
the engine notes a refusal, the controller drains onto the transcript. It is
drained per event and again in `finally`, so a refusal arriving beside the
last frame -- or while the stream was dying -- is still shown. Deliberately
not an EventTarget: draining on the controller's own schedule means a refusal
paints with the frame it arrived beside rather than forcing its own publish.

`noteDropped` is the public form of the reducer's private `drop`, because
`apply` can only record events it was handed and a frame that failed to decode
never became one.

Found while writing this: my first drain sat at the end of the `catch` block,
so it only ran when the turn had already failed. The paired test for a refusal
on the last frame caught it. It is in `finally` now, before the turn settles.

WIRED, and proved wired. Eight mutations, one per hop, each now failing:

  engine discards refusals again            fail=2
  engine reports a refusal for every frame  fail=2
  controller drains but discards            fail=3
  controller skips the final drain          fail=1
  sink drains without clearing              fail=1
  boot does not give the sink to the ctrl   fail=1
  boot hands engines a dead callback        fail=1
  registry declines to forward onIgnored    fail=1

The last two mattered most. Removing `dropSink` from createRunController, and
dropping the registry's forward, BOTH left the suite green on the first pass:
every hop worked in isolation and the ends were not joined. That is the exact
mechanism-connected-to-nothing this commit exists to undo, reproduced by me
while undoing it. Both are now pinned by composition tests -- one through
createApp, one through the real enginesFor rather than a test double.

Every case is paired, so wiring that invented a dropped row would fail too.

907 tests, 0 failed. boundaries: 130 files, no violations.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 14:57:19 +01:00
Mervin Praison f6e89e0bf6 Merge pull request #4558 from MervinPraison/fix/mobile-truncate-and-tick
fix(mobile): a mutation audit of the wiring nobody tested
2026-08-28 14:49:11 +01:00
MervinPraisonandClaude Opus 5 fb31e74a67 fix(mobile): the contract prover read an output format node 24 does not use
`check (node 24)` failed while `check (node 22.18)` passed on the identical
commit. The prover greps the spawned fixture's output for `not ok`, which
assumes TAP. Node's default reporter differs by version: 22 emits TAP when
stdout is a pipe, 24 emits the spec reporter, so the same failing case prints

  not ok 3 - FIXTURE no_start: a run emits start first and ...      (node 22)
  ✖ FIXTURE no_start: a run emits start first and ...              (node 24)

The child's exit status was correct on both; only the line the test read for
the case NAME was missing, so the prover reported that the contract failed for
the wrong reason.

The reporter is now forced with `--test-reporter=tap`, so the format is a
decision this test makes rather than a default it inherits.

Reproduced and verified against a real node 24.12.0 rather than assumed: the
default reporter there prints `✖`, forcing tap restores `not ok`, and the full
suite is 895/895 on both 22.23 and 24.12.

This is the same mistake as the ones this branch has been fixing, made by me
while fixing them: a test that observes an incidental property rather than the
thing it means to check. CI caught it because it runs two node versions, which
is the only reason it was visible at all.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 14:38:45 +01:00
MervinPraisonandClaude Opus 5 8d1d07ef12 docs(mobile): gaps.md asserted two things that are not true
This file exists to record claims that outran the code. It had two of its own.

1. Gap 3 ended "Removing the join removes the opportunity." The join was never
   removed -- only the TYPE duplication was. view-model.ts still calls
   findApproval(table, pending.approvalId), and replacing that with
   `table.entries[0]` passed the entire suite, because every test had exactly
   one approval in flight. With two outstanding, the second row rendered the
   first's decision: an `rm -rf /` prompt shown as already-allowed against a
   decision nobody made. The join is correct and is now pinned in both
   directions; the opportunity still exists, and taking it now fails.

2. The closing line said what remained was "the Tauri Rust crate". The crate
   exists -- src-tauri/src/{lib,commands,main}.rs, the shell module and two
   test files -- with a CI job compiling it on ubuntu-22.04 and macos-15. The
   same line silently dropped route->view dispatch, which the body of the file
   lists as open and which is still open: main.ts imports neither screens.ts
   nor mount.ts and never subscribes to the router, so tapping "Settings"
   pushes onto a stack nothing reads.

"Every gap in this file is now closed" is narrowed to "every gap ORIGINALLY
RECORDED in this file", because a later validation pass found fourteen
mutation survivors and three product defects that were never gaps in the sense
this file records. A list of closed gaps is not evidence that nothing is wrong,
and the old phrasing invited exactly that reading.

The remaining-work list is now specific and each item was checked against the
tree before being written -- including that the in-process engine's stated
blocker (bare `crypto` and `events` on the Agent import graph) is already fixed
upstream, so the rationale for omitting that engine no longer holds.

One defect is recorded as knowingly open: remote-http discards decode
rejections, so a malformed frame makes a tool vanish and the turn renders as a
clean answer.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 14:35:25 +01:00
MervinPraisonandClaude Opus 5 9b6de002e5 fix(mobile): upstream-parity printed success over a typechecker that never ran
The tool's own header says "a check that silently stops covering anything is
worse than no check". It then did exactly that.

`drift` keeps only output lines mentioning `parity.ts`. When the compiler
could not start at all, the caught error's message is `spawn npx ENOENT` --
which mentions no such thing. Zero drift lines, so:

    upstream-parity: the real Agent still satisfies PraisonAgent
    exit 0

over nothing whatsoever. Reproduced by running it with npx off PATH.

A tsc that genuinely ran and failed prints diagnostics; a spawn failure, a
missing binary or a killed process prints none. The run is now required to
have either exited 0 or produced `error TS`-shaped output, and anything else
exits 1 saying the check did not run rather than reporting a pass.

Two tests, driven as subprocesses because this is a property of how the tool
is invoked rather than of anything it exports: one empties PATH and requires a
failure whose reason says so and which does NOT also print the success line;
its pair runs with the real PATH and requires the success, so a checker that
failed unconditionally cannot satisfy the first while breaking every build.

Same class as the ungoverned-roots hole fixed earlier on this branch, and the
third instance this session of a checker reporting green over work it did not
do.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 14:34:22 +01:00
MervinPraisonandClaude Opus 5 71f346a558 test(mobile): the conformance suite's "can FAIL" was a claim, not a mechanism
conformance.ts says the suite can fail. conformance.test.ts said each broken
engine "is asserted to fail a NAMED case". Two validators found independently
that neither was true, and I confirmed it: gutting a contract case so it
asserts nothing left all 890 tests green.

`describeEngineContract` was only ever called with CONFORMING engines -- the
scripted fake, praisonai-ts, remote-http. The five can-fail tests build a
broken engine and then hand-run assertions about the REDUCER instead. That is
a real check of the reducer and no check at all of the contract, so the
contract could silently stop checking anything while reporting a clean pass.
This is the exact failure mode tools/check-boundaries.mjs guards against one
directory over, in the file that claims to be the seam's guarantee.

Worse, one of those fixtures cannot fail the suite even in principle: the case
it purports to break is manufactured by `finish()` in the reducer, so it
passes regardless of the engine.

contract-fixture.ts registers the REAL contract against an engine broken in
one named way, selected by argv, and conformance.test.ts spawns it once per
mode -- asserting the process fails AND that the failing line names the case
that mode breaks, not merely that something failed.

  no_start             -> a run emits start first and exactly one terminal...
  tool_failure_as_ok   -> a failed tool is reported failed and never inferred...
  empty_is_fine        -> an empty stream is an error, not an empty answer
  decide_always_true   -> deciding an unknown approval returns false rather...

A `none` mode is spawned too and must PASS. Without it a runner that reported
failure for everything -- a bad path, a syntax error, a process that never
started -- would satisfy all four assertions while proving nothing.

Verified by gutting each of the four cases in turn: every one is now caught,
where every one previously passed.

NODE_TEST_CONTEXT is stripped from the child. Left in place, the spawned
fixture believes it is a worker of the parent run and reports through it
rather than printing its own verdict, so the output the test reads carries no
result -- which is how the first version of this passed while measuring
nothing, and is the same class of bug it exists to catch.

HONEST LIMIT: four of the contract's twenty-one cases have a prover. The
remaining seventeen can still be gutted silently. Adding a mode per case is
mechanical; the mechanism is here and the four highest-value rules are the
ones covered -- start-first, ok-is-the-only-signal, empty-is-an-error, and no
false success on an unknown approval.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 14:32:57 +01:00
MervinPraisonandClaude Opus 5 df818f6da5 fix(mobile): every setting was accepted and ignored, and Stop confirmed refusals
Two product defects. Not test gaps -- the app did these wrong on a device, and
no test could reach either.

1. THE ENGINE WAS BUILT WITH A STUB THAT ANSWERED undefined

main.ts built the engine list with `facadeStub()`, whose `get` returned
undefined for everything, and the engine closed over it at boot. The stub's
own comment said it was "replaced by the real one immediately after". It was
not: nothing rebuilt the engine.

So settings were loaded from disk at boot purely to be discarded. A user's
engine address fell back to the hardcoded http://127.0.0.1:8765, and
`engineId` -- a declared setting, with a `choices` list a picker renders --
was ignored outright, because main.ts passed the literal "remote-http".

createApp now builds the real facade before selecting the engine, which it
could always have done: settings load first precisely so the engine choice and
its credentials come from them. The factory takes `(persistence, settings)`,
facadeStub is deleted, and the persisted engineId is preferred over the
caller's default.

An existing test caught the first attempt, and was right to. Reading the value
with `get()` alone let a def's DEFAULT outrank the caller's explicit argument
-- `get` returns the default for a key nobody ever touched, which is
indistinguishable from a deliberate choice. That is the same class of bug in
the opposite direction: a setting nobody set silently overriding the thing
that asked. The store now tracks which keys came from storage or an accepted
`set`, and `chosenStringOr` consults `isSet` before preferring one.

2. STOP CONFIRMED A CANCELLATION THE ENGINE REFUSED

The controller returns the engine's own answer, and both the scripted engine
and the conformance contract go out of their way to keep that boolean honest
-- "a Stop button that confirms a cancellation which never happened is worse
than one that reports it could not."

The one caller discarded it. The button label flips off `turn.phase`, which
settles either way, so a refused Stop looked exactly like an accepted one
while the run kept generating and kept billing. It is now announced in the
assertive live region. The decision is extracted as `stopNotice` so a test
calls it -- driving `mount` end to end needs a whole fake SSE transport, and
this is the part that can be wrong.

Every mutation pinned in both directions.

STILL OPEN, deliberately not fixed here: the remote-http engine discards
decode rejections (`if (isDecoded(outcome)) yield`), so a malformed frame
makes a tool vanish and the turn renders as a clean answer. The reducer's
Dropped type, the view model's dropped row and seven user-facing strings are
all unreachable because of it. Fixing it needs a channel from the engine to
the transcript that does not exist yet; half-wiring it would be the same
mechanism-connected-to-nothing this commit is undoing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 14:26:56 +01:00
MervinPraisonandClaude Opus 5 ee00172e78 test(mobile): the approvalId join, and the last hop before the pixel
Three more survivors, found by a validation pass and confirmed by mutation
here before anything was written.

view-model.ts approvalRow -- `findApproval(table, pending.approvalId)`
replaced by `table.entries[0]`. Its own comment says "Looked up by approvalId
in both -- never zipped by index", and the whole approvalId design exists to
stop position-pairing. It survived the entire suite because every existing
test has exactly ONE approval in flight, where the first entry and the right
entry are the same object.

With two outstanding, the second row renders the first row's decision: an
`rm -rf /` prompt shows as already-allowed, with dead buttons, against a
decision the user never made. The defect the design prevents, reintroduced at
the last hop before the DOM. Both directions are pinned -- an approval with no
entry of its own must render pending rather than borrow a stranger's.

dom.ts -- `el.dataset["status"] = row.status` hardcoded to "ok", and
`durationKnown ? durationLabel : UNKNOWN` collapsed to the label. Both
survived. The ok-is-the-only-signal chain is airtight through decode, the
reducer and the view model -- every mutation aimed at those dies -- and then
the attribute the CSS keys off was written by nothing that checked it. A
failed tool rendered with the success styling. `seconds: null` means the
engine never observed the call begin, which is not zero; rendering the label
regardless states a duration that was never measured, and that invariant was
written in a comment two lines above code nothing tested.

Each pinned in both directions, so a test that catches one mutation is not
satisfied by code broken the opposite way.

Tests only; no product source changed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 14:19:56 +01:00
MervinPraisonandClaude Opus 5 eabd38def7 perf(mobile): truncate segmented 40,000 clusters to keep 119
The earlier fix here moved the length check BEFORE segmentation, which made
short input 42x faster. It does nothing for the case that actually hurts: at
the real call site `max` is PREVIEW_CHARS = 120, so any long output always
overflows the check and always paid full segmentation anyway.

`buildTranscript` re-derives every tool preview on EVERY publish, roughly
thirty times a second during a streaming answer. One tool returning 40kB of
single-line JSON -- an API response, fetched HTML, a minified file, base64 --
therefore cost ~10ms per publish on a laptop, and a phone is 3-5x slower, so
that one tool blows the frame budget on its own. `firstLine()` rescues the
multi-line case; single-line output gets no such reprieve.

Only `max + 1` clusters are ever needed: `max` to keep, and one more to learn
whether anything was cut. `Intl.Segmenter.segment()` returns a LAZILY iterable
Segments -- `Array.from` is what forces the whole string.

  10kB one line    9.7ms -> 0.04ms     61x
  40kB one line    9.7ms -> 0.05ms    197x
  200kB one line  47.9ms -> 0.12ms    407x

Byte-identical to the old implementation over 50,010 differential cases: 21
hand-picked (ZWJ family emoji, skin-tone modifiers, regional-indicator flags,
Devanagari conjuncts, max of 0/1/NaN/Infinity) plus 50,000 randomised.

The regression test is timed rather than counted, because the segmenter is not
injectable. The threshold has ~170x headroom over the fixed implementation and
still fails the revert by more than 2x. Both reverts were confirmed to fail it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 13:18:21 +00:00
MervinPraisonandClaude Opus 5 545adb0ce4 test(mobile): two fakes discarded the delay, so three tuned constants were free
A second audit pass, this time asking what the FAKES excuse rather than what
the tests assert. Five more mutations survived the suite; all five now fail.

The root cause for three of them: TimePort declares `setTimer(cb, ms)` and
`every(ms, cb)`, and both fakes accepted the callback and threw the delay
away. A fake that takes fewer arguments than its port silently excuses every
caller from getting them right.

  publish-gate  setTimer(reopen, UNPAINTED_REOPEN_MS) -> setTimer(reopen, 0)
                The gate reopens on the next macrotask: publish per token,
                which is the entire thing the module exists to prevent. It
                survived even the file's own positive control, because that
                control never involves a timer.

  controller    every(maxDelayMs, ...) -> every(60_000, ...)
                The coalescer's flush tick runs once a minute. This restores
                verbatim the defect fixed three commits ago -- a short answer
                arriving in one lump with zero intermediate paints -- and the
                test written for that fix did not notice, because the fake
                fired every callback on every tick regardless of period.

`FakeTime.tick()` now fires only the intervals whose period has actually
elapsed on the fake clock, so the period is load-bearing in every existing
test rather than in one bespoke assertion. `FakeScheduler` records the delay
it was armed with, and the gate's is asserted against the constant AND
against zero -- pinning it to the constant alone still passes if the constant
itself is set to 0, which is the same shipped behaviour.

  coalescer     `if (pending === "") openedAt = nowMs` -> unconditional
                Every arriving token restarts the delay budget, so on a device
                -- where tokens arrive continuously -- `waited` never reaches
                maxDelayMs and text paints only at the byte cap. The existing
                trickle test pushes ONE token, which makes the two versions
                identical. It needs more than one drop to be a trickle.

  boot.ts       delete the lifecycle subscription, or background -> active
                `assert.equal(stopped || true, true)` is true for every value
                of `stopped` -- and `stopped` was in fact false: the test never
                started a run, so `controller.stop()` returned early and
                `cancel` was never reached. The comment beside it shows the
                assertion was neutralised rather than the setup fixed.
                The turn is now genuinely in flight before backgrounding, and
                a paired test proves only `background` stops it -- otherwise
                every resume notification would kill the turn on screen.

Tests and fakes only; no product source changed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 13:18:21 +00:00
MervinPraisonandClaude Opus 5 4db87b4df6 fix(mobile): a new top-level directory defeated both seams with a clean pass
boundaries.json has said this since the day it was written:

  "governedRoots exists so a NEW top-level directory is reported as ungoverned
   rather than silently exempt. A rule that stops covering new code while still
   reporting a clean pass is worse than no rule."

Nothing implemented it. governedRoots was only a list of directories to WALK,
so a directory absent from that list was never visited. Adding features/ and
importing both '@tauri-apps/api' and 'praisonai' from it -- the two seams the
whole layer arrangement exists to defend -- still printed "126 files checked,
no violations" and exited 0.

That is the failure the fixtures in depgraph.test.mjs exist to prevent, one
level up: not a rule that gets an answer wrong, a rule that reports success
over code it never looked at.

The scan is now ungovernedRootsIn(root, config) in depgraph.mjs, following the
note at the top of check-boundaries.mjs -- the rule's logic stays a library
tests can call. It takes root as an argument so the tests point it at fixture
trees rather than mutating the real one. check-boundaries.mjs runs it before
the walk and exits 1 with the two ways to resolve it.

ungovernedRoots is the other half: tools, src-tauri and docs are now
deliberately declared exempt. Listing a directory is a decision someone made;
not listing one is caught.

Seven tests, including a shallow-scan case (features/a/b/c/x.ts), a no-source
case so docs/ cannot cry wolf, and one asserting the real boundaries.json still
accounts for every directory on disk -- which is the test that will fail on
somebody's future branch. Verified against three broken implementations:
always-[], report-everything, and shallow-only.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 13:18:21 +00:00
MervinPraisonandClaude Opus 5 e2f60aa736 test(mobile): a refused approval must not render as a confirmed one
Two more mutation survivors, both silent.

controller.ts: `ok ? acknowledge : reject` collapsing to always-acknowledge.
The engine reports that it did not accept a decision -- it restarted, or the
approval expired -- and the row still renders resolved. The user sees their
Deny confirmed against a command the engine never denied, which is the one
failure the approval table exists to make visible. Both directions are now
pinned: an always-reject controller trips three tests.

settings/store.ts: the secret guard inside persist(). Deleting it survived
because nothing can reach it -- `set` refuses a secret-flagged key and `load`
drops one, so through the public API no secret ever enters the map. That is
precisely why it needed a test: an unreachable guard is one nobody notices the
deletion of, and it is the last line between an API key and a plaintext
settings file the day a new write path forgets the rule.

The guard is now `plainOnly(values, byKey)`, following the rule this file
already states -- extract the expression so a test can call it rather than
asserting it appears in the source. Behaviour is unchanged; persist() calls
the extracted function. A falsy-value test pins the other half, since a
truthiness filter here would reset every disabled toggle on the next load.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 13:18:21 +00:00
praisonai-triage-agent[bot]andMervin Praison e17c6efbfe fix(mobile): fake time could rewind, unlike the clock it stands in for
FakeTime.advance mutated its own offset with no monotonic guard, while the
createFakeClock it composes throws on a negative advance. A mistaken negative
advance silently rewound nowMs -- a state a monotonic TimePort never produces --
letting a pacing test pass or fail for the wrong reason. Guard it the same way.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 13:18:21 +00:00
MervinPraisonandClaude Opus 5 102dc3d5cb test(mobile): cover the four composition edges no test reached
A mutation run over the package found five files that no test executes at
all -- they are wiring, so nothing imports them, and the suite was green
with them arbitrarily broken. Four survivors, each silent on a device:

app/src/platform.ts -- always returning the web shell. An on-device build
falls back to the browser shell: no safe-area insets, no OS back gesture,
no lifecycle. It looks like it works.

adapters/src/web/http.ts -- deleting `signal: request.signal`. Stop reaches
the engine and never reaches fetch, so the request keeps streaming and
keeps billing after the user cancelled.

adapters/src/web/http.ts -- flipping sendsFromNative to true. That flag is
the claim that an API key does not enter the webview heap; nothing checked
the claim against the adapter making it.

core/src/chat/session.ts -- transposing the two same-typed arguments of
persistenceFor's record(). Every stored conversation gets the user's
message and the model's reply the wrong way round, and a transposed
transcript still renders and still round-trips.

Each test was confirmed to fail against its mutation before being kept.
Tests only; no source file changed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 13:18:21 +00:00
MervinPraison bdfb46978c fix(mobile): short answers did not stream at all
The coalescer has two bounds: flush at maxBytes, or flush after
maxDelayMs. Only the first was ever connected. `coalescer.tick()` was
never called, and `TimePort.every` -- whose own doc comment calls it
"the coalescer's flush tick" -- had zero call sites outside tests.

So the time bound never fired. Measured before this: a 130-character
answer produced ZERO intermediate paints and arrived in one lump when
the run ended, making time-to-first-visible-token the whole answer's
latency. A long answer advanced in 256-character jumps roughly every
420ms. The publish gate's MAX_HELD_CHARS = 96, tightened deliberately
for mobile with a comment explaining why 256 was too coarse, never bound
either -- the coalescer upstream was already withholding more.

In an app whose stated first priority is streaming performance, the
streaming was not connected.

The tick is stopped in `finally`, before anything else: one firing after
the turn settled would paint into a finished transcript and keep a timer
alive for the app's lifetime.

Its frame is deliberately NOT gated. The gate skips intermediate paints
when the renderer cannot keep up; this frame exists precisely because
nothing has painted for maxDelayMs, so gating it reintroduces the stall
it was added to prevent.

WHY NO TEST NOTICED: testing/src/fake-time.ts implemented `every` as
`() => () => {}`. A fake that cannot observe the thing it stands in for
makes every test using it pass for the wrong reason. It drives real
interval callbacks now, and can advance the clock.

And my first attempt at the test was itself vacuous -- it asserted "more
than one paint carried text", which the `end` flush satisfies with the
tick removed. The assertion is on PARTIAL text now: without a tick the
text goes straight from "" to the whole answer. Verified by removing the
tick again: it fails.

838 tests, boundaries clean.
2026-08-28 13:18:21 +00:00
MervinPraison 074e269964 fix(mobile): the XSS guarantee had no test, and truncate re-segmented every frame
Two findings from an adversarial audit. The first is the most serious
thing found in this package.

app/src/dom.ts HAD NO TEST AT ALL. Changing `el.textContent = row.text`
to `el.innerHTML = row.text` left all 830 tests passing. Model output,
tool results and summarised web pages all flow through that line, so the
file's own header -- "makes that structurally impossible rather than
depending on an escaping function nobody re-reads" -- was guaranteed by
nothing.

The only thing in the suite that mentioned applyOps asserted the BUNDLE
CONTAINED THE SYMBOL. Rename it and that breaks; gut its body and it
passes. That is the shape of a test that looks like coverage and is not.

Six tests now, and they kill what survived: the innerHTML swap fails 2,
and `b.disabled = false` -- the guard that stops a double tap sending
two decisions for one approval -- fails 1. Both verified by re-applying
the mutation.

TRUNCATE SEGMENTED BEFORE CHECKING. `graphemes(text)` ran over the whole
string, then the length check discovered it had not been needed. A code
unit count is an upper bound on graphemes, so the check can come first.

Measured: a 20-character preview went from 8.83us to 0.21us, 42x. It
matters because buildTranscript re-derives every tool preview on EVERY
publish -- with eight 40kB tool results that was 44ms per publish and
~361MB of transient allocation across one turn, to regenerate previews
that had not changed since the tool returned.

839 tests, boundaries clean.
2026-08-28 13:18:21 +00:00
Mervin Praison 45ded8bbdc Merge pull request #4559 from MervinPraison/fix/train-pid-test-race
fix(train): the pid-ownership test read a file before anything wrote to it
2026-08-28 14:16:48 +01:00
Mervin Praison c410099386 Merge pull request #4557 from MervinPraison/ci/mobile-rust-tests
fix(mobile): the Tauri crate does not build on main, and no CI job would say so
2026-08-28 14:08:55 +01:00
MervinPraisonandClaude Opus 5 4b5a032db1 fix(train): the pid-ownership test read a file before anything wrote to it
test_the_recorded_pid_owns_the_trainer_signalable_by_group failed in CI with

    AssertionError: recorded pid 2568 does not own the trainer
    (trainer's parent is )
    assert '' == '2568'

and it blocks unrelated pull requests when it does.

The stand-in trainer records its parent with

    ps -o ppid= -p $$ | tr -d ' ' > trainer_ppid

and the wait loop broke as soon as all three files EXISTED. Shell redirection
creates its target before the command on the left produces a byte, so
trainer_ppid exists and is empty for as long as `ps` takes to run. The loop
saw it, stopped waiting, and compared '' against the recorded pid.

It is not a slow machine and not a short timeout -- the window is the runtime
of `ps`, so a longer wait does not help. Waiting for CONTENT does.

Measured on the same shell shape, 300 trials each:

    existence-only (before)  read an empty value 296/300
    content (after)          read an empty value   0/300

The real loop polls at 100ms, which usually outlasts `ps` -- which is why this
passes most of the time and fails under CI load rather than every run.

The emptiness check is also asserted separately from the ownership comparison,
so a genuine timeout reads as "the trainer never recorded its parent" instead
of masquerading as the ownership bug this test exists to catch.

Separately, and NOT fixed here: this test is unsafe to run on a host without
`setsid` (macOS). The launch script cannot make the wrapper a group leader,
so the wrapper stays in pytest's process group and the test's
`os.killpg(os.getpgid(recorded), SIGTERM)` terminates the test runner itself
-- pytest exits -15 before reporting. On Linux CI `setsid` exists and the
group is isolated, so this does not affect the pipeline. Flagged for whoever
owns this suite; fixing it is a design choice about how the test spawns.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 14:08:19 +01:00
praisonai-triage-agent[bot]andMervin Praison 2dd29b5550 fix(mobile): build the webview before cargo so generate_context! finds ../dist
The rust shell job ran cargo test/clippy directly, but tauri::generate_context!
expands frontendDist (../dist) at compile time and dist/ is gitignored -- absent
on a fresh checkout. Both matrix jobs failed to compile before any test ran.
Add setup-node + npm ci + npm run build to create ../dist first.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 12:39:21 +00:00
MervinPraison 5e129a0fda fix(mobile): the Tauri crate does not build on main, and no CI job would say so
Found by an adversarial validation pass. Two problems, and the second is
why the first survived.

THE BUILD IS BROKEN. capabilities/default.json names a permission
`allow-back-gesture-result` that does not exist, so tauri-build fails
before anything compiles:

    Permission allow-back-gesture-result not found

An app command registered through invoke_handler is always reachable and
has no ACL entry to grant -- only plugin commands do. Removed, with the
reason recorded in the file's own description so it is not re-added.

NOTHING WOULD HAVE CAUGHT IT. mobile.yml's path filter already triggers
on src-tauri changes and it has no cargo job at all -- so a crate with
14 tests had none of them run, and a crate that does not compile merged
green. That is precisely the failure this workflow's header says it was
created to prevent, one language over.

The new `shell` job runs cargo test and clippy -D warnings on
ubuntu-22.04 AND macos-15. Both, because the crate is cfg-heavy: the
back-gesture fallback is #[cfg(target_os = "android")] / ios /
not(mobile), and a cfg mistake compiles perfectly on whichever host you
happened to try.

Clippy then caught something real in my own test: asserting on a const
inside a test is compile-time-known, so it proved nothing at runtime.
The floor is a const assertion in the source now -- lowering
ANSWER_TIMEOUT_MS stops the crate compiling rather than failing a test.
Verified by lowering it to 50: E0080, build refused.

14 Rust tests, cargo check clean, clippy clean with -D warnings.
2026-08-28 13:34:01 +01:00
Mervin Praison 00d88c0a2f Merge pull request #4556 from MervinPraison/feat/mobile-tauri-shell
feat(mobile): the Tauri shell crate, with the back gesture arbitrated
2026-08-28 13:28:15 +01:00
praisonai-triage-agent[bot]andMervin Praison 46f058b6b5 fix(mobile): grant main-window IPC capability; portable seam-test path
- add src-tauri/capabilities/default.json so the main webview can
  subscribe to the four shell events (core:event) and invoke
  back_gesture_result; without a capability Tauri v2 silently rejects
  these, disabling the entire shell contract (Greptile P1).
- shell-seam.test.mjs: use fileURLToPath(import.meta.url) instead of
  URL.pathname so the test resolves on Windows checkouts, matching the
  sibling check-boundaries.mjs (Greptile P2).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 12:17:31 +00:00
MervinPraison 6ddf200daa feat(mobile): the Tauri shell crate, with the back gesture arbitrated
The Rust side of the two seams. Everything the app does lives in the
webview; this crate exists to tell it four things a browser cannot know
and to act on the one answer it sends back.

THE EVENT NAMES ARE A CONTRACT, and a rename is silent: the webview
simply stops receiving that event and lays out as though the phone had
no notch, no keyboard and no lifecycle. Nothing throws. So there are two
guards -- a Rust test pinning each constant to a literal, and a Node
test that READS both sources and compares them, catching a rename in
either language. Verified by renaming one on the Rust side: both fail.

BACK-GESTURE ARBITRATION is the piece with real failure modes, and it is
pure with the platform injected, so all of them are tested on a laptop:

  - The answer may never come. bridge.invoke swallows every rejection on
    the TypeScript side, so silence is indistinguishable from success --
    a bundle that failed to load would leave a back button that does
    nothing FOREVER, which is worse than one that exits. Hence a
    watchdog.
  - There is no correlation id. The webview sends { handled } and
    nothing else, so two presses close together produce two answers Rust
    cannot tell apart, and the second could pop an activity the first
    decided to keep. Dropping while pending is the only correct option
    on this side of the frozen contract.
  - A late answer after a timeout must not fall back twice. That is the
    bug this design is most likely to ship, and it has its own test.

LIFECYCLE maps Suspended to "background" rather than "inactive", and the
comment says why rather than leaving it to be rediscovered: boot.ts only
flushes on background, and on iOS the app can be killed while suspended
with no further callback. Mapping to inactive would lose the transcript
on every backgrounding. The cost -- a control-centre pull-down stopping
the run loop -- is the cheaper mistake.

panic = "abort" is deliberately NOT set, unlike the desktop crate:
mobile_entry_point wraps the app in catch_unwind, and abort turns a
readable message into a bare SIGABRT on a device with no console.

Icons are generated placeholders in the product's own accent colour,
written by hand as RGBA because Tauri rejects RGB.

14 Rust tests, 830 TypeScript tests, boundaries clean.
2026-08-28 13:11:48 +01:00
praisonai-triage-agent[bot] 84b0fb12b6 chore(release): bump praisonai-agents [skip ci] 2026-08-28 12:07:27 +00:00
Mervin Praison ac2b847a64 Merge pull request #4555 from MervinPraison/fix/mobile-close-gaps
fix(mobile): close the last three gaps
2026-08-28 13:05:05 +01:00
praisonai-triage-agent[bot]andMervin Praison bc5f594e09 fix(mobile): resolved approvals are non-actionable; decouple dropped-reason formatter
- view-model: an approval kept from an ended turn (resolved) now renders
  non-actionable even though its decision-table entry stays pending, closing
  the P1 where a finished run could still submit/retry a decision it can no
  longer deliver.
- strings: route droppedEvents through a module-level enDroppedReason instead
  of the sibling en.droppedReason, since bundle.ts lifts each string function
  off the table and there is no reliable `this` (Greptile P2).
- test: cover an ended-turn approval rendering as history, not a live prompt.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 12:02:55 +00:00
MervinPraison 40a0ed1516 fix(mobile): close the last three gaps
None of them blocked shipping, which is exactly why they sat. Two were
one bad day away from mattering.

GAP 2 -- settle() deleted the approvals, so a finished transcript could
never say "you were asked to allow rm -rf / and the turn ended first",
which is the thing a reader most wants to see afterwards. They are kept
now and marked resolved: NOT ACTIONABLE rather than NOT RECORDED. The
pair test is the one that matters -- marking everything resolved would
satisfy the audit-trail case while making every live prompt
unanswerable.

`resolved` is optional rather than required, and the reason is in the
type: absent IS the answer for every approval that has not been settled,
which is all of them until a turn ends. Writing `resolved: false` at a
dozen construction sites would be noise carrying no information, and
noise is where a real value gets mistyped unnoticed.

GAP 3 -- ApprovalEntry is now literally PendingApproval & { state }. The
duplication forced the view model to join the two by approvalId at
render time, and that join is where index-pairing gets reintroduced --
pairing an approval by position is the defect the whole approvalId
design exists to prevent. Removing the join removes the opportunity.

GAP 7 -- a dropped event said `wrong_msg_id` to a user, which tells them
nothing. It reads as a sentence now AND keeps the tag: translating the
tag away would make the one searchable string in the message
unsearchable for whoever they report it to. An unknown tag passes
through rather than becoming "unknown reason", because a newer engine
can invent one and the tag is then the only information present.

Verified by reverting each: emptying the approvals again fails 2 tests,
returning bare tags fails 3.

An existing test also caught me adding a string key with no sample call,
which is the guard working exactly as intended.

826 tests, boundaries clean. Every gap in docs/gaps.md is now closed.
2026-08-28 12:56:33 +01:00
Mervin Praison f84a3bb759 Merge pull request #4553 from MervinPraison/claude/issue-4549-20260828-1050
fix: record trainer pid, not wrapper shell, in remote runner
2026-08-28 12:52:53 +01:00
praisonai-triage-agent[bot]andMervin Praison a74957bddd fix: record trainer pid so stop() signals the trainer, and quote wrapper safely
The wrapper now backgrounds the trainer and records the trainer's own pid
($!) instead of the wrapper shell's, so stop() sends SIGTERM to the process
actually doing the work rather than a sh -c wrapper that never forwards it
(which orphaned the run). The wrapper program is quoted once with shlex.quote
for the outer shell, so a stray apostrophe or metacharacter in the workdir or
dataset name can no longer close the sh -c string and rewrite the command.

Updated the regression test to assert the recorded pid IS the trainer and that
a TERM to it actually stops the trainer, and to kill all descendants on exit.

Addresses Greptile P1 (stop non-forwarding), P1 (nested quoting), P2 (test leak).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 11:47:39 +00:00
praisonai-triage-agent[bot]andMervinPraison 1d14f14129 fix: record trainer pid, not wrapper shell, in remote runner (fixes #4549)
_launch_script recorded $! from the outer shell -- the transient subshell
that backgrounded the whole `&&` list -- not the process that owns the
trainer. status() then read `unknown` for a live run and stop() signalled
the wrong pid. The wrapper now records its own pid from inside (echo $$),
and a brace group with </dev/null releases ssh's stdin/stdout so the launch
returns immediately.

Co-authored-by: MervinPraison <[email protected]>
2026-08-28 11:45:44 +00:00
Mervin Praison 786023e003 Merge pull request #4554 from MervinPraison/feat/mobile-route-dispatch
feat(mobile): route to screen dispatch, with the transcript retained
2026-08-28 12:43:32 +01:00
Mervin Praison 5e5b010ca1 Merge pull request #4552 from MervinPraison/fix/mobile-wire-session
fix(mobile): actually wire the session, and seed the keyboard snapshot
2026-08-28 12:43:22 +01:00
Mervin Praison 26a5128beb Merge pull request #4551 from MervinPraison/claude/issue-4547-20260828-1049
fix: remote stop signals whole process group so no orphaned GPU work
2026-08-28 12:43:16 +01:00
Mervin Praison c6bbf78290 Merge pull request #4550 from MervinPraison/claude/issue-4548-20260828-1049
fix: expand ~ in remote workdir instead of creating a dir named '~'
2026-08-28 12:43:12 +01:00
praisonai-triage-agent[bot]andMervin Praison 99575502db fix: stop() re-probes whole process group, not just wrapper pid
Addresses Greptile P1: after SIGTERM to the group, stop() checked only the
recorded wrapper pid. A wrapper can exit while a child (dataloader worker,
torchrun rank) survives SIGTERM, so stop() reported "stopped" while paid GPU
work continued. Re-probe now scans the whole process group; "stopped" means
no member of the group remains. Adds a test where the wrapper exits and a
SIGTERM-trapping child survives — stop() must raise, not lie.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 11:05:25 +00:00
praisonai-triage-agent[bot]andMervin Praison 6e601910b3 fix(mobile): distinguish pinch-zoom from a keyboard in the web shell
Pinch-zoom shrinks visualViewport.height below innerHeight exactly as a
software keyboard does, so readKeyboardHeight reported a phantom keyboard
the moment a page was zoomed -- most visibly at construction, where a page
opened already zoomed seeded a positive height and pushed the composer up
on the first frame. A software keyboard never changes scale, so scale > 1
is zoom, not a keyboard; guard on it and report 0.

The live publishKeyboard path now reads through the same readKeyboardHeight
function, so the clamp and the zoom guard live in one place and cannot drift
between the first frame and every frame after it.

Positive control: fake-window gains setZoom(scale); reverting the guard fails
the two new tests. Documents the remote-http persistence scope in gaps.md so a
reviewer's finding is not later read as an oversight.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 10:59:31 +00:00
MervinPraison ec8def249d feat(mobile): route to screen dispatch, with the transcript retained
The view models existed and nothing mounted them: the app rendered one
hard-coded transcript and the router drove nothing.

The decision layer is pure, and it is pure for a specific reason -- the
cases that matter are about what should be KEPT, and a test that can
only inspect a DOM cannot tell "rebuilt identically" from "left alone".

The chat screen is retained; the others are not. Rebuilding the
transcript on every return from settings throws away every row node and
the scroll position with it, so a reader who scrolls up, checks a
setting and comes back is dumped at the bottom of the conversation. That
is the most annoying bug a chat app can have and it is invisible to a
test that only asserts the right rows are present afterwards.

Retention is a cost, not a default: a retained screen holds its DOM for
the app's lifetime. Worth it for a live transcript with scroll state,
not for a settings form that should re-read its values on entry.

Mount happens BEFORE hide, so no navigation shows an empty frame.

A mutation run caught a vacuous test of my own here, and it is worth
recording. The fake DOM logged append and remove but not visibility --
so reordering hide before mount changed nothing it could see, and the
test named "mounting happens BEFORE hiding" asserted nothing at all. The
fake records show/hide now and the mutation fails it.

Other mutations caught: destroying a retained screen instead of hiding
it fails 2, unmounting a retained screen fails 1, and treating
same-route as always-noop fails 1.

816 tests, boundaries clean across 123 files.
2026-08-28 11:58:10 +01:00
praisonai-triage-agent[bot]andMervin Praison 92d6536e6f fix: guard remote workdir against leading-hyphen option injection
A workdir like `-rf` passed the character-class check but would be parsed
as an option by the remote mkdir/cd. Add `--` to end option parsing so it
is always treated as a path; tilde expansion is unaffected since the shell
expands `~` before either command sees the argument. Adds a regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 10:56:35 +00:00
MervinPraison 2007cc5d40 fix(mobile): actually wire the session, and seed the keyboard snapshot
A reviewer amended gaps 4 and 5 back to open after I marked them closed,
and was right on both. The same mistake twice: a mechanism existing is
not a mechanism working.

GAP 4 -- "mechanism landed, not wired". createSession was called.
RunPersistence.record was called. Nothing connected them, and the two
signatures did not even line up -- record(prompt, answer) against
record(request, answer) -- which is why it went unnoticed. So record()
never ran in a real turn and no conversation was ever saved, while the
gap read as closed from either end.

persistenceFor() is now the named adapter where the two vocabularies
meet. Naming it rather than inlining a lambda at the call site is
deliberate: this is the one place they touch, and a lambda buried in
composition is a seam nobody finds later.

AppDeps.engines is now a FACTORY taking the persistence, so there is no
way to obtain the engine list without being handed the thing engines
write through. A pre-built array was the original bug; the type refuses
it now. That change surfaced every unwired call site immediately, which
is the argument for making it a type rather than a convention.

The session is also built BEFORE engine selection, because the engine
writes through it.

GAP 5 -- "property added, snapshot not seeded". keyboardHeightPx was
declared = 0 and only ever updated by an event, so a component mounting
while the keyboard was already up laid out at 0 for one frame and then
jumped: precisely the bug the synchronous property was added to prevent.
Seeded from visualViewport at construction now.

Both verified by positive control -- reverting either fails a named
test. 798 tests, boundaries clean.
2026-08-28 11:52:18 +01:00
praisonai-triage-agent[bot]andMervinPraison d0e9271d9f fix: remote stop signals whole process group so no orphaned GPU work (fixes #4547)
RemoteRunner.stop() killed only the recorded pid -- the wrapper shell -- so
the trainer's children (dataloader workers, torchrun ranks) survived on the
GPU, reparented to init. Resolve the process group on the host, signal -$pgid,
then re-probe: return True only when the process is actually gone, raise when
it survives SIGTERM, and refuse to signal the ssh session's own group. Launch
under setsid where available (portable fallback for macOS).

Co-authored-by: MervinPraison <[email protected]>
2026-08-28 10:51:51 +00:00
praisonai-triage-agent[bot]andMervinPraison e58c473846 fix: expand ~ in remote workdir instead of creating a dir named '~' (fixes #4548)
resolve_workdir quoted the workdir before the remote shell saw it, so ~
never expanded and a directory literally named '~' was created. Validate
the workdir (same rule as settings.validate) and pass it unquoted so the
remote shell expands it; use mkdir -m 700 so a shared box's umask does
not decide who can read the shipped config and dataset.

Co-authored-by: MervinPraison <[email protected]>
2026-08-28 10:51:30 +00:00
Mervin Praison 59b1f16298 Merge pull request #4546 from MervinPraison/docs/mobile-gaps-truthful
docs(mobile): gaps.md was asserting three gaps that are closed in code
2026-08-28 11:48:07 +01:00
praisonai-triage-agent[bot]andMervin Praison 41da51d50d docs(mobile): correct gaps 4 & 5 — mechanism landed but not wired end-to-end
Gap 4 (session/live-turn identity) and Gap 5 (keyboard snapshot) were marked
CLOSED but are only half-closed in code:

- Gap 4: core/src/chat/session.ts implements RunPersistence, but no engine is
  wired to it in production — createPraisonTsEngine gets a RunPersistence only
  in tests, boot.ts never passes session to an engine, and remote-http has no
  persistence. Turns are not recorded end-to-end.
- Gap 5: ShellPort.keyboardHeightPx exists but both adapters init it to 0 and
  only correct on a later event; unlike insets it is not seeded synchronously,
  so a warm resume still paints at 0 then jumps.

Gap 6 is genuinely closed (defs/subscribe/setSecret/clearSecret on the facade)
and stays closed. Reclassifies 4 & 5 as still-open and updates the audit
preamble to match its own rule: a gap is closed only when wired end-to-end.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 10:32:49 +00:00
MervinPraison 8d2696c87a docs(mobile): gaps.md was asserting three gaps that are closed in code
Found while auditing what is outstanding. Gaps 4, 5 and 6 were fixed and
left recorded as open:

  4  no user-message side       closed by core/src/chat/session.ts
  5  keyboard not synchronous   closed by ShellPort.keyboardHeightPx
  6  settings cannot enumerate  closed by defs/subscribe/setSecret

That is not a filing problem. It is the same failure this package is
built against, one level up: a document asserting a state nobody
re-checked. And it has a measurable cost -- a stale P0 list for
praisonai-ts sent two agents to verify nine "blockers" that were every
one of them already fixed. Useful work, since it turned up three real
bugs nobody had listed, but not the work it was supposed to be.

So the file now carries the rule as well as the state: close a gap here
in the same commit that closes it in code, and verify before believing
what it says. Every claim in it is checkable in under a minute.

Genuinely still open, and recorded as such: gaps 2, 3 and 7, none of
which blocks shipping. Plus route-to-view dispatch and the Tauri crate,
which are unbuilt work rather than defects and are named separately so
they are not mistaken for gaps.

794 tests, boundaries clean.
2026-08-28 11:27:57 +01:00
MervinPraison 18ca9e4497 docs: auto-update documentation parity trackers [skip ci] 2026-08-28 10:26:14 +00:00
Mervin Praison 037eeb45c8 Merge pull request #4544 from MervinPraison/claude/issue-4542-20260828-0944
fix: add incident tracking with alert-fatigue latching for scheduled jobs
2026-08-28 11:25:39 +01:00
praisonai-triage-agent[bot]andMervin Praison 1bfaa36006 fix: preserve meaningful numeric codes in incident error signatures
Blanket digit deletion in normalise_error collapsed distinct failures
(HTTP 404 vs 500, exit code 12 vs 34) onto one signature, suppressing the
promised re-alert on a changed error (Greptile P1). Now strip only volatile
numbers — hex/uuid blobs, long digit runs (timestamps/pids/request ids) and
numbers fused into identifier words — while preserving short standalone codes
so genuinely different failures re-alert and changing request ids still group.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 10:24:45 +00:00
MervinPraison ec367dff67 docs: auto-update api.md [skip ci] 2026-08-28 10:14:58 +00:00
Mervin Praison 13267296a5 Merge pull request #4545 from MervinPraison/claude/issue-4541-20260828-0943
fix: share durable idempotency store across all delivery paths
2026-08-28 11:14:31 +01:00
Mervin Praison 81887d4039 Merge pull request #4543 from MervinPraison/claude/issue-4540-20260828-0942
fix: standalone praisonai schedule run poller for store-backed jobs
2026-08-28 11:14:11 +01:00
Mervin Praison 73cf52c42d Merge pull request #4539 from MervinPraison/claude/issue-4538-20260828-0919
feat: durable, queryable per-session lifecycle event log
2026-08-28 11:14:01 +01:00
Mervin Praison b7c7488ebd Merge pull request #4479 from MervinPraison/claude/issue-4452-20260827-1140
fix: translate graded reasoning effort to provider-native controls + persist per session
2026-08-28 11:13:52 +01:00
Mervin Praison 0695df4414 Merge pull request #4470 from MervinPraison/claude/issue-4446-20260827-1120
fix: streaming input guardrail, tool error-dict handling, per-instance token scoping
2026-08-28 11:13:47 +01:00
praisonai-triage-agent[bot]andMervin Praison e6c358a7f8 fix: release durable reservation on dead-target/cancel; honour PRAISONAI_HOME (#4541)
Address reviewer findings on the shared durable idempotency store:

- DeliveryRouter: a send suppressed for a dead target, or cancelled while
  waiting on the rate limiter or during the adapter send, now releases the
  durable reservation claimed up-front by _is_duplicate(). Previously the key
  stayed 'inflight' for up to the 15-min lease, wrongly suppressing legitimate
  retries. CancelledError is a BaseException so it is handled explicitly.
- SchedulerDelivery: the durable store path now honours $PRAISONAI_HOME
  (falling back to ~/.praisonai) so two isolated deployments under one OS
  account keep separate dedup ledgers instead of cross-suppressing.
- Tests: add dead-target and cancellation release regressions.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 10:02:26 +00:00
praisonai-triage-agent[bot]andMervinPraison 8c6670d3cb fix: share durable idempotency store across all delivery paths (fixes #4541)
Promote the existing durable SqliteIdempotencyStore to an injectable dedup
substrate the DeliveryRouter uses on every path, so restart-safe
effectively-once delivery is no longer gateway-only. The lightweight
SchedulerDelivery now injects the same store the gateway uses, and a failed
send releases the durable reservation so it stays retryable.

Co-authored-by: MervinPraison <[email protected]>
2026-08-28 09:49:34 +00:00
praisonai-triage-agent[bot]andMervinPraison e18c94a4b3 fix: add incident tracking with alert-fatigue latching for scheduled jobs (fixes #4542)
Add a pure, dependency-free incident brain beside RunRecord: error_signature()
groups failures by a stable sha256 of the normalised error, and IncidentTracker
folds the run stream into de-duplicated operator alerts — alert once per
incident, re-alert only on a changed signature, resolve on recovery, and an
optional N-consecutive-failure threshold. Core-only decision logic so every
engine (gateway, wrapper, standalone) shares one policy; delivery reuses the
existing DeliveryTarget plumbing.

Co-authored-by: MervinPraison <[email protected]>
2026-08-28 09:48:16 +00:00
praisonai-triage-agent[bot]andMervin Praison b80cf44e19 fix: fully scope per-instance token reports to owned agents (#4446)
Address Greptile P1 findings: by_model, total_interactions and
recent_interactions in the token reports were still copied from the
process-global TokenCollector summary, leaking other concurrent
PraisonAIAgents instances' usage. Re-derive by_model and
total_interactions from this instance's own interaction records and
filter recent_interactions to owned agent names.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 09:46:51 +00:00
cacc4d3694 fix: make --thinking take effect and restore reasoning effort on resume (#4452)
Route the backward-compatible thinking_budget setter through the unified
reasoning_effort path so the CLI's post-construction override actually
reaches the provider request (previously dormant: it only set
_thinking_budget, never emitted the native parameter). Also sync a
post-construction change into an already-built LLM instance.

Wire session persistence + resume for reasoning effort in the CLI:
persist the graded effort alongside the model in apply_cli_session_continuity,
add find_session_reasoning_effort, and restore it on --continue/--session
next to the existing model restore, mirroring the model precedent (#3685).

Add unit coverage for the thinking_budget setter reaching the request and
for the wrapper effort-restore helper.

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

Co-Authored-By: Claude <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 09:46:34 +00:00
praisonai-triage-agent[bot]andMervinPraison 3947111e33 fix: add standalone praisonai schedule run poller for store jobs (fixes #4540)
Store-backed jobs added via `praisonai schedule add` previously never fired
unless the gateway/BotOS/UI host was running, because only those hosts wired
the store poller. `praisonai schedule start` runs the unrelated agents.yaml
engine, so a plain CLI user's jobs silently never executed.

- Core: add `ScheduleLoop.run_forever(poll_seconds, owner_id)` — a foreground,
  blocking counterpart to `start()` that reuses the same lease-safe `fire_due`
  tick body (at-most-once `claim_due_jobs`, no double-fire alongside a gateway).
- Wrapper: add `praisonai schedule run` CLI command that reuses the existing
  `schedules_runner` bridge executor (delivery fallback included) and blocks
  until Ctrl-C.

No new engine, params, or exports beyond one core convenience method and one
CLI command.

Co-authored-by: MervinPraison <[email protected]>
2026-08-28 09:45:38 +00:00
Mervin Praison 40248e6a66 Merge pull request #4533 from MervinPraison/claude/issue-4532-20260828-0707
fix: close sync/async and multi-agent safety divergences in praisonaiagents core
2026-08-28 10:44:27 +01:00
Mervin Praison 8b4582969a Merge pull request #4478 from MervinPraison/claude/issue-4453-20260827-1140
fix: surface provider content-filter/refusal/length-truncation as explicit terminal outcomes
2026-08-28 10:44:15 +01:00
MervinPraison 65202faa83 docs: auto-update api.md [skip ci] 2026-08-28 09:42:37 +00:00
praisonai-triage-agent[bot]andMervin Praison 7192520223 fix: prioritize agent-owned provider block over stale backend reason; reset stop reason on async native path
- agent.py: last_stop_reason returns a specific agent-owned block/refusal/
  truncation before backend fallbacks so a stale backend "max_steps" no longer
  masks the OpenAI-native classification.
- chat_mixin.py: reset _last_stop_reason at the start of the async native turn
  (mirrors the sync _chat_completion reset) to prevent cross-run leakage.
- execution_mixin.py: narrow the stop-reason lookup to AttributeError so a
  lookup failure is not silently converted into a successful "completed".
- tests: add regressions for agent-owned-vs-backend precedence.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 09:42:25 +00:00
Mervin Praison bbf2eb0a86 Merge pull request #4473 from MervinPraison/claude/issue-4452-20260827-1114
fix: first-class reasoning effort — provider-native translation + session persistence
2026-08-28 10:42:06 +01:00
MervinPraison badc44cb53 docs: auto-update documentation parity trackers [skip ci] 2026-08-28 09:41:12 +00:00
Mervin Praison 9616aba5c3 Merge pull request #4472 from MervinPraison/claude/issue-4453-20260827-1114
fix: surface provider content-filter/refusal/length-truncation as explicit terminal outcomes
2026-08-28 10:40:32 +01:00
Mervin Praison 2c9eaeaac8 Merge pull request #4471 from MervinPraison/claude/issue-4441-20260827-1120
fix: show setup UI when a restored Train view hides it on first run
2026-08-28 10:39:42 +01:00
Mervin Praison 63ce797927 Merge pull request #4469 from MervinPraison/claude/issue-4281-20260827-1114
fix: async guardrail offload + async tool circuit breaker
2026-08-28 10:39:23 +01:00
Mervin Praison 211c8604dd Merge pull request #4462 from MervinPraison/claude/issue-4446-20260827-1114
fix: stream input guardrail, tool error dicts, and per-team token scoping
2026-08-28 10:39:02 +01:00
praisonai-triage-agent[bot]andMervin Praison 182f11e78b fix: durable event log — monotonic cursor, atomic cross-connection seq, metadata round-trip
Addresses reviewer feedback (Greptile) on the SqliteEventLog sink:

- Cursor no longer rewinds after a full prune: per-session seq is allocated
  from a persistent event_seq high-water-mark table instead of MAX(seq) over
  live rows, so deleting every row for a session never resets its counter.
- Seq allocation is atomic across connections/processes sharing one DB file:
  read+bump+insert run inside a BEGIN IMMEDIATE transaction, eliminating the
  read-then-insert race that previously dropped colliding events.
- Caller-provided event.metadata now persists (metadata_json column) and is
  restored on query, with the durable seq/session_id overlaid on top.
- Additive migration for pre-existing databases: adds metadata_json and seeds
  event_seq from MAX(seq) so the next append continues above existing rows
  instead of colliding on the primary key.

Adds regression tests: post-full-prune monotonicity, metadata round-trip,
cross-connection no-collision, and legacy-schema migration.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 09:30:48 +00:00
praisonai-triage-agent[bot]andMervinPraison 64ec27878b feat: durable, queryable per-session lifecycle event log (fixes #4538)
Add an opt-in SQLite sink for the existing EventBus so lifecycle events
survive a restart and can be read back per session with a cursor.

- New core module bus/event_log.py: EventLogProtocol + SqliteEventLog
  (stdlib sqlite3, WAL, best-effort append, monotonic per-session seq,
  cursor query(session_id, after_seq), age/count prune).
- EventBus.attach_sink()/detach_sink()/has_sinks: sinks receive every
  published event (including the no-subscriber fast path) and their
  failures are swallowed so a full/slow log never breaks a turn.
- Export EventLogProtocol/SqliteEventLog from bus/__init__ (lazy).
- Backward compatible: a bare Agent() attaches no sink, preserving the
  zero-overhead in-memory path; the in-memory ring buffer is unchanged.

Co-authored-by: MervinPraison <[email protected]>
2026-08-28 09:23:40 +00:00
praisonai-triage-agent[bot] 6fb4a38d9e Merge pull request #4537 from MervinPraison/claude/issue-4536-20260828-0819
fix: async scheduler backoff honors stop(), atomic daemon-state writes, drain all SQLite connections on close
2026-08-28 09:20:56 +00:00
praisonai-triage-agent[bot] 32e6a020d5 Merge pull request #4530 from MervinPraison/claude/issue-4529-20260828-0557
fix: provision %APPDATA%\PraisonAI on first primary desktop launch
2026-08-28 09:15:03 +00:00
MervinPraison c6666dfad6 docs: auto-update documentation parity trackers [skip ci] 2026-08-28 08:41:19 +00:00
praisonai-triage-agent[bot] dd16dbde2c Merge pull request #4526 from MervinPraison/claude/issue-4525-20260827-1713
fix: remove dead jittered_backoff import in _chat_completion error branch
2026-08-28 08:40:44 +00:00
praisonai-triage-agent[bot]andMervin Praison fef96b18c8 fix: keep thread-affine SQLite connections tracked on close() (Greptile P1)
When check_same_thread=True, a worker-created connection cannot be closed
from close()'s caller thread — sqlite3 raises ProgrammingError. The prior
code swallowed it then cleared _conns, silently dropping (leaking) those
connections untracked. Now such connections are retained in _conns so they
stay reachable for a later close() from their owning thread; cleanly-closed
connections are still removed. Default check_same_thread=False path unchanged.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 08:29:27 +00:00
praisonai-triage-agent[bot] 7e032640ab Merge pull request #4524 from MervinPraison/claude/issue-4523-20260827-1615
fix: delegate persistence doctor backend detection to canonical resolver
2026-08-28 08:26:09 +00:00
praisonai-triage-agent[bot]andMervinPraison 75c48371a3 fix: interruptible async retry backoff, atomic daemon-state writes, drain all SQLite connections on close (fixes #4536)
- async_agent_scheduler: retry backoff now waits on _stop_event via
  _sleep_or_stop(), so stop()/shutdown is no longer held for the full cap
- _base_scheduler: daemon state written atomically (tempfile + os.replace)
  and JSON/OS errors logged at warning instead of being silently swallowed
- sqlite conversation store: track every thread-local connection and drain
  them all in close() so a thread pool no longer leaks N-1 connections

Co-authored-by: MervinPraison <[email protected]>
2026-08-28 08:22:25 +00:00
praisonai-triage-agent[bot] 3af0eaa03f Merge pull request #4522 from MervinPraison/claude/issue-4504-20260827-1352
fix: fetch_url must not follow redirects past the approval gate (desktop audit #4490)
2026-08-28 08:20:10 +00:00
praisonai-triage-agent[bot] d7dcff8e9a Merge pull request #4521 from MervinPraison/claude/issue-4503-20260827-1351
fix: replace non-functional alert()/prompt() in desktop UI
2026-08-28 08:13:23 +00:00
Mervin Praison cd695dd10d Merge pull request #4534 from MervinPraison/fix/merge-gate-pending-count
fix(ci): exclude workflow_run probes from merge-gate pending count
2026-08-28 08:58:22 +01:00
praisonai-triage-agent[bot] 40e4ad280d Merge pull request #4520 from MervinPraison/claude/issue-4497-20260827-1341
fix: check settings write result before applying in desktop UI
2026-08-28 07:29:05 +00:00
praisonai-triage-agent[bot] cf32a84e36 Merge pull request #4518 from MervinPraison/claude/issue-4502-20260827-1346
fix: report runAction failures in desktop app instead of failing silently
2026-08-28 07:25:33 +00:00
praisonai-triage-agent[bot]andClaude f5120de076 fix: close async breaker exception gap and per-instance approval context cache
Address two Greptile P1s on PR #4533:

1. Async tool exceptions now count toward the circuit breaker. The
   post-invoke record block was skipped when the tool *raised* (control
   jumped to the except handler), so raised failures never opened the
   breaker. Record a breaker failure in the exception handler too, matching
   the sync path's breaker.call.

2. The in-context is_already_approved/mark_approved cache is now keyed by
   the per-instance scope_id (falling back to agent_name), so two same-named
   agents sharing an execution context can't pre-authorize each other via the
   name-keyed fast-path checked before the session lookup. Threaded scope_id
   through approve_sync/approve_async, the two _check_tool_approval mark sites,
   and the module-level wrappers.

Adds regression tests: async breaker opens after 5 raised failures and is
per-instance; context cache is isolated by scope_id with name-keyed fallback.

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

Co-Authored-By: Claude <[email protected]>
2026-08-28 07:23:54 +00:00
praisonai-triage-agent[bot]andMervin Praison decded7879 test(ci): cover workflow_run probe exclusion in merge-gate pending count
Extract countSubstantiveMergeGateRuns helper and add direct regression
tests so a reload of workflow_run probes into the pending cap is caught
by the self-test (addresses Greptile coverage gap on PR #4534).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 07:23:30 +00:00
MervinPraisonandCursor 5e610d8cf5 fix(ci): exclude workflow_run probes from merge-gate pending count
Auto-dispatch probe runs were counted toward the max-pending cap, blocking
repository_dispatch for merge-ready PRs even when no assess job was queued.

Co-authored-by: Cursor <[email protected]>
2026-08-28 08:19:25 +01:00
praisonai-triage-agent[bot] 90157cec9a Merge pull request #4517 from MervinPraison/claude/issue-4501-20260827-1345
fix: stop advertising desktop MCP servers as launched (#4501)
2026-08-28 07:17:27 +00:00
praisonai-triage-agent[bot] 391d2da1bd Merge pull request #4519 from MervinPraison/claude/issue-4500-20260827-1346
fix: spawn desktop engine with resolved env, not the inherited one
2026-08-28 07:16:50 +00:00
praisonai-triage-agent[bot]andMervinPraison 23e13eff01 fix: close sync/async and multi-agent safety divergences in core SDK (fixes #4532)
Three sibling code paths silently diverged from the "safe by default,
multi-agent safe, async-safe" guarantee. Each mirrors an existing correct
pattern that the sibling path never received:

1. Async tool execution now engages the per-agent circuit breaker. Added
   shared helpers (_circuit_breaker_precheck / _circuit_breaker_record) in
   tool_execution.py using the same instance-scoped breaker key as the sync
   path, and wired them into _execute_tool_async_impl so a persistently
   failing tool short-circuits with the circuit_open signal the async retry
   loop already checks for, instead of being hammered indefinitely.

2. ApprovalCallback.__call__ now routes async approve_fn through the shared
   run_coroutine_from_any_context bridge instead of hand-rolling a new event
   loop with no running-loop guard, replacing an uncaught loop-crash inside a
   running loop (e.g. FastAPI async route) with the codebase's standard clean
   error.

3. Session-scoped ("this session") tool approvals are now keyed by the
   per-Agent-instance _approval_scope_id via a new scope_id parameter on
   approve_sync/approve_async (threaded into _is_session_scoped /
   _persist_scoped_decision), so same-named agents no longer inherit each
   other's human-granted shell-execution approvals. agent_name is retained
   for name-keyed lookups (per-agent backend, risk level, ask rules, durable
   "always" persistence). Same fix applied to the doom-loop approval path.

Co-authored-by: MervinPraison <[email protected]>
2026-08-28 07:14:50 +00:00
Mervin Praison 7c0870d621 Merge pull request #4531 from MervinPraison/fix/ts-sdk-final-review-parity
fix(ci): require SDK Value table in FINAL reviews and extend TS merge-gate parity
2026-08-28 07:52:57 +01:00
MervinPraisonandCursor d8932275b7 fix(ci): require SDK Value table in FINAL reviews and extend TS merge-gate parity
FINAL architecture reviewer prompt now mandates the Phase 1 table with an
explicit SDK value row and TS AGENTS.md routing. Merge-gate helpers treat
src/praisonai-ts/ as SDK scope for test enforcement and CI check patterns.

Co-authored-by: Cursor <[email protected]>
2026-08-28 07:51:38 +01:00
praisonai-triage-agent[bot] 2359ed02b8 Merge pull request #4516 from MervinPraison/claude/issue-4495-20260827-1339
fix: DELETE /chats/<id> report actual delete result
2026-08-28 06:23:57 +00:00
MervinPraison ec08beeeb0 docs: auto-update feature parity trackers [skip ci] 2026-08-28 06:03:01 +00:00
praisonai-triage-agent[bot] 3862f7e5e8 Merge pull request #4505 from MervinPraison/claude/issue-4488-20260827-1334
fix: invalidate cached OpenAI client when credentials change
2026-08-28 06:02:31 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy a6a5e0a63c fix: provision %APPDATA%\PraisonAI on first primary desktop launch (fixes #4529)
The user data directory was only created inside provision_engine, which
runs solely after the user clicks "Get started" on the first-run setup
screen. A clean machine that merely opened the app -- or one whose data
folder was deleted to repair a corrupt state -- therefore left no Roaming
directory at all, indistinguishable in a clean-start QA check from a shell
that never started. Create it eagerly in setup() (the primary-only path;
a secondary never reaches it) so the directory exists the moment the app
is running, whether or not provisioning has begun. Best effort, so a shell
never fails to start because it could not create its data directory.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-08-28 05:59:38 +00:00
praisonai-triage-agent[bot] e61d807cce Merge pull request #4481 from MervinPraison/fix/audit-gaps
fix(train): the three commits that missed the merge — including a failed run reporting success
2026-08-28 05:56:58 +00:00
praisonai-triage-agent[bot]andMervin Praison 8eeb595e75 test: assert unapproved redirect target is never contacted
Strengthen the fetch_url redirect regression test per reviewer
feedback (Greptile). The test previously only checked the secret
body was absent from the result -- an implementation that contacts
the unapproved loopback target and discards its response would still
pass. Record requests to server B and assert it receives none, since
the request itself is a side effect (actions, logging, rate limits).

Mutation-verified: reverting the one-line server.py fix makes the
strengthened test fail on both the leaked body and the recorded hit.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 03:42:21 +00:00
praisonai-triage-agent[bot]andMervinPraison 881c676a0b fix: fetch_url must not follow redirects past the approval gate (fixes #4490)
The approval card authorises one URL, but urllib.request.urlopen follows
3xx by default, so a 302 could send the fetch to any host or port --
including this engine on loopback -- and return its body to the model
under an approval that never named it. Fetch through an opener that
refuses redirects and reports the unapproved target instead.

Part of the #4504 desktop audit tracking issue (finding #1 of 16, high).

Co-authored-by: MervinPraison <[email protected]>
2026-08-28 03:42:21 +00:00
praisonai-triage-agent[bot]andMervin Praison 3b0101d1e1 fix: clear-all follows storage when a delete partially fails
The bulk "Delete all conversations" path returned early on any failure
without checking whether the active conversation was among the deletes
that succeeded, leaving its transcript on screen while gone on disk.
Track the active id and blank the transcript when it was deleted, even
if a different conversation's delete failed. Adds a regression test.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 03:42:08 +00:00
praisonai-triage-agent[bot]andMervinPraison faabad0b87 fix: DELETE /chats/<id> report actual delete result (fixes #4495)
The engine answered {"ok": true} whether or not the chat was removed,
and both UI delete paths blanked the transcript regardless. A read-only
or synced data dir left the conversation closed on screen and back in
the sidebar on the next refresh.

- engine/server.py: do_DELETE returns 400 on an invalid id and 500 on
  an OSError instead of swallowing both.
- ui/index.html: the sidebar delete and runAction('clear') now check the
  response before blanking the transcript, matching the per-message
  delete already in place.
- tests: engine DeleteChats route tests and two buttons.test.mjs cases.

Co-authored-by: MervinPraison <[email protected]>
2026-08-28 03:42:08 +00:00
praisonai-triage-agent[bot] 660bd52ce7 Merge pull request #4506 from MervinPraison/claude/issue-4490-20260827-1336
fix: stop fetch_url following redirects past the approval gate
2026-08-28 03:38:50 +00:00
praisonai-triage-agent[bot]andMervin Praison e8b82ad9ab test: reflect POST /settings echo in stub so reconciliation reads the write
After rebasing onto main, saveCfg reconciles the server's echoed settings back
into CFG. The stub returned the original settings for both GET and POST, so the
persisted step was overwritten and rapid text-size steps collapsed. Echo the
written patch like the real engine does.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 03:23:22 +00:00
praisonai-triage-agent[bot]andMervin Praison 56ace29799 fix: serialize settings writes and step text size from pending target
Moving the CFG mutation to after the await made rapid text-size steps read
stale state, collapsing bursts and letting overlapping writes land out of
order. Queue writes FIFO and step from the pending target so bursts
accumulate (fixes Greptile P1).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-28 03:21:10 +00:00
praisonai-triage-agent[bot]andMervinPraison d4059e99a8 fix: check settings write result before applying in desktop UI (fixes #4497)
saveCfg mutated CFG/cfg and applied prefs before POSTing /settings, with no
r.ok check and no catch. A rejected write (e.g. across the engine restart the
base_url/api_key rows warn about) left the theme button dead, toggles diverged
from stored state, and setTextSize toasted success while every write failed.

saveCfg now persists first, mutates only on r.ok, toasts on failure, and
returns a boolean. apply() and setTextSize gate their visible effects on it.

Co-authored-by: MervinPraison <[email protected]>
2026-08-28 03:21:10 +00:00
praisonai-triage-agent[bot] 6e8005137b Merge pull request #4507 from MervinPraison/claude/issue-4498-20260827-1337
fix: persist launch_at_login's honest result, not the request
2026-08-28 03:17:51 +00:00
praisonai-triage-agent[bot] 5b89d345af Merge pull request #4474 from MervinPraison/claude/issue-4455-20260827-1124
feat: add first-class local (terminal) channel to gateway
2026-08-28 03:11:46 +00:00
praisonai-triage-agent[bot] cb66f3760e Merge pull request #4508 from MervinPraison/claude/issue-4491-20260827-1337
fix: stop the trainer when the desktop engine quits
2026-08-28 00:48:45 +00:00
praisonai-triage-agent[bot] 50cb25def1 Merge pull request #4468 from MervinPraison/claude/issue-4449-20260827-1114
fix: sandbox AutoGen code exec, enforce tool_timeout on ACP/LSP tools, honour per-agent budgets
2026-08-28 00:04:34 +00:00
praisonai-triage-agent[bot] 7f4ec8533d Merge pull request #4509 from MervinPraison/claude/issue-4493-20260827-1339
fix: drain trainer pipe when log write fails
2026-08-28 00:00:43 +00:00
praisonai-triage-agent[bot] 399fe5b37b Merge pull request #4510 from MervinPraison/claude/issue-4492-20260827-1338
fix: persist desktop training state so a restart survives the live run
2026-08-27 21:40:39 +00:00
praisonai-triage-agent[bot] c206d81a57 Merge pull request #4511 from MervinPraison/feat/mobile-tool-events
feat(mobile): map upstream tool events, and enable capabilities.tools
2026-08-27 20:48:18 +00:00
MervinPraison 6b03ab65ad docs: auto-update documentation parity trackers [skip ci] 2026-08-27 20:46:47 +00:00
praisonai-triage-agent[bot] 19251d2653 Merge pull request #4465 from MervinPraison/claude/issue-4456-20260827-1117
feat: skill frontmatter automation contract (schedule + deliver)
2026-08-27 20:46:05 +00:00
praisonai-triage-agent[bot] 4e98bae727 Merge pull request #4514 from MervinPraison/claude/issue-4494-20260827-1339
fix: escape quotes in desktop markdown renderer
2026-08-27 18:21:48 +00:00
praisonai-triage-agent[bot] 4c2fe00031 Merge pull request #4515 from MervinPraison/claude/issue-4499-20260827-1341
fix: make Windows engine adoption reachable (separator/case-insensitive path compare)
2026-08-27 17:19:47 +00:00
MervinPraison 13f63553e5 docs: auto-update api.md [skip ci] 2026-08-27 17:18:07 +00:00
praisonai-triage-agent[bot] 68d8fa1418 Merge pull request #4464 from MervinPraison/claude/issue-4454-20260827-1114
fix: make standalone scheduler delivery outcome truthful (#4454)
2026-08-27 17:17:35 +00:00
praisonai-triage-agent[bot]andMervinPraison e04df21014 fix: remove dead jittered_backoff import in _chat_completion error branch (fixes #4525)
Co-authored-by: MervinPraison <[email protected]>
2026-08-27 17:14:24 +00:00
praisonai-triage-agent[bot]andMervinPraison 924e1fd267 fix: delegate persistence doctor backend detection to canonical resolver (fixes #4523)
Route the persistence doctor's URL->backend detection through the tested
PraisonAIDB._detect_backend instead of hand-rolled substring sniffing.
Doctor-specific lenient fallbacks (sqlite/qdrant/memory and the chroma
special-case) are retained via a CLI-local wrapper.

Co-authored-by: MervinPraison <[email protected]>
2026-08-27 16:17:01 +00:00
MervinPraison a8e46fbf6d Merge remote-tracking branch 'origin/main' into fix/audit-gaps 2026-08-27 16:36:41 +01:00
MervinPraison 7ddc85fcc7 docs: auto-update feature parity trackers [skip ci] 2026-08-27 15:23:39 +00:00
praisonai-triage-agent[bot] 17600f497f Merge pull request #4513 from MervinPraison/claude/issue-4487-20260827-1333
fix(praisonai-ts): restorable Agent conversations via typed getHistory/setHistory
2026-08-27 15:23:10 +00:00
praisonai-triage-agent[bot]andMervinPraison 6d7df69908 fix: stop the trainer when the desktop engine quits (fixes #4491)
The trainer is spawned with start_new_session=True, so on macOS/Linux a
signal aimed at the engine's process group never reaches it. The engine's
exit handler was `lambda *_: sys.exit(0)`, which let the fine-tune reparent
to init and keep the GPU with nothing left able to find or stop it.

The exit handler now calls Trainer.stop() first, which terminates the
trainer's own process group before the engine exits. Well within the 2s
Engine::shutdown allows before SIGKILL.

Co-authored-by: MervinPraison <[email protected]>
2026-08-27 15:23:00 +00:00
praisonai-triage-agent[bot] 615ccc07de Merge pull request #4512 from MervinPraison/claude/issue-4496-20260827-1340
fix: non-object JSON in chats/ no longer drops the connection
2026-08-27 15:18:48 +00:00
praisonai-triage-agent[bot]andMervin Praison 8cf894817e fix: surface MCP add errors via in-app dialog, not dimmed toast
The toast intentionally paints below the settings scrim (see layout.test
'the settings scrim covers the toast'), so blank-name and backend-error
feedback shown while the MCP overlay is open was dimmed and unreadable --
the same invisible-feedback bug this PR set out to fix. Route both MCP
error paths through askConfirm, whose .confirm-back (z-index 90) paints
above the overlay (z-index 80).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 14:44:40 +00:00
praisonai-triage-agent[bot]andMervin Praison 4c13181ad4 fix: harden load_chat against non-object JSON so /projects and /search survive
The PR fixed list_chats() but /projects and /search call load_chat() on the
same files, where a valid-but-non-object JSON (the app's own array export)
returned the raw list and crashed on .get(...) with AttributeError -- dropping
the connection on exactly the file the PR set out to handle.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 14:41:37 +00:00
praisonai-triage-agent[bot]andMervin Praison db09f7c2fe fix: swallow log.close() failure so the run still finalizes
close() flushes, so a full disk or revoked directory can raise from the
reader's finally block just as a per-line write can. If it escapes, it
skips proc.wait()/run.finish() and wedges the run in "running" forever --
the same permanent wedge this reader exists to prevent, moved to the last
line. Guard the close and add a regression test that reproduces the wedge
on the unfixed code.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 14:37:48 +00:00
praisonai-triage-agent[bot]andMervin Praison a482e76409 fix: collect inherited env with vars_os to avoid abort on non-Unicode entries
std::env::vars() panics on any non-Unicode env key/value, and the release
profile sets panic = "abort", so a single non-Unicode inherited variable would
abort the desktop app before the engine started. Switch to vars_os() and drop
undecodable entries; spawn_env only handles String, so nothing usable is lost.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 14:37:05 +00:00
praisonai-triage-agent[bot]andMervin Praison 590beb84dc fix: reconcile launch_at_login toggle from server response in UI
The settings client optimistically set launch_at_login=true and ignored
the POST response, so when the OS declined to register a login item the
toggle stayed on for the session even though the server persisted false.
saveCfg now reads the response and reconciles any keys the server wrote
back, and the launch_at_login handler surfaces the explanation.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 14:32:06 +00:00
praisonai-triage-agent[bot]andMervin Praison 71197fa808 fix(praisonai-ts): preserve restored tool name and invalidate cache on setHistory
Two fixes to the restorable-conversation feature:

- Preserve the tool `name` through getHistory/setHistory. toAISDKPrompt sets
  the tool-result part's toolName from msg.name; without it a restored tool
  history sent to a non-OpenAI (AI SDK) backend emits an empty toolName and is
  rejected. Added optional name to AgentMessage, copied it in copyMessage, and
  carried it in the start() replay.
- Clear responseCache in setHistory so a repeat prompt is re-evaluated against
  the restored history instead of returning the pre-restore prompt-keyed answer.

Added regression tests for both; full suite: 1141 passed, 0 failed.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 14:30:43 +00:00
praisonai-triage-agent[bot]andMervin Praison 32dd2aa7d3 fix: make desktop training state writes atomic and close the pre-spawn gap
Two valid restart-recovery bugs from PR review:

- _persist now writes a temp file, fsyncs and os.replaces it, so an engine
  killed mid write can never leave partial JSON that _reload silently skips
  (which would lose the live run from history, stop() and the single-GPU guard).
- start() persists the run (pending) before the supervisor thread exists, and
  the spawn-failure branch persists its FAILED state, so a child spawned before
  the first post-spawn _persist is never left with no run.json.

Adds two tests. PID reuse is left as the documented minimal tradeoff.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 14:28:02 +00:00
praisonai-triage-agent[bot]andMervin Praison 26980342ad fix: return locally-built client to close getOpenAIClient cache race
Return the client captured in a local before the awaited Logger.debug so a
concurrent credential change or resetOpenAIClient() during that yield cannot
hand a caller the wrong client or null. Adds two race regression tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 14:18:56 +00:00
praisonai-triage-agent[bot]andMervin Praison 237fcd3b5f docs(mobile): update stale seam docs to the five-variant tool contract
The tool_call/tool_result mapping landed but the seam documentation still
described the pre-tool three-variant union, contradicting the new support.
Aligns engine.ts, agent-api.ts and conformance.test.ts headers and gaps.md
so the capability status reads consistently: tools supported, approvals the
one remaining gap. Docs/comments only; no behaviour change.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 14:14:56 +00:00
praisonai-triage-agent[bot]andMervinPraison 0c0d7ab6c6 fix: replace non-functional alert()/prompt() in desktop UI (fixes #4503)
WKWebView under Tauri has no JS dialog panel, so prompt() returns null
and alert() shows nothing. This made "Move to project" inert and hid MCP
add errors. Add an askText() helper mirroring askConfirm(), and route
the three sites through askText/toast. Update the button test stubs to
model the real platform (prompt->null, alert throws) and add a
contextmenu test asserting a POST /project request is issued.

Co-authored-by: MervinPraison <[email protected]>
2026-08-27 13:54:38 +00:00
praisonai-triage-agent[bot]andMervinPraison 1af9ec92ac fix: spawn desktop engine with resolved env, not the inherited one (fixes #4500)
venv_resolve::spawn_env strips PYTHONPATH/PYTHONHOME/PYTHONSTARTUP and sets
VIRTUAL_ENV/PATH, but had no production caller -- supervisor::start inherited
the shell's environment and only added PYTHONUTF8/PYTHONIOENCODING. A user with
PYTHONHOME or PYTHONPATH exported got the engine pointed at a foreign stdlib or
site-packages, and the module written to prevent exactly that never ran.

start now takes the resolved env and applies it with env_clear + envs; main.rs
builds it from the already-resolved VenvLayout. Adds a regression test proving a
poisoned PYTHONHOME/PYTHONPATH never reaches the spawned child.

Co-authored-by: MervinPraison <[email protected]>
2026-08-27 13:49:25 +00:00
praisonai-triage-agent[bot]andMervinPraison 6df05aef88 fix: report runAction failures in desktop app instead of failing silently (fixes #4502)
Wrap the settings runAction body in runActionInner and catch failures with
a toast, so Export/Check for updates/MCP no longer resolve into silence when
the engine is unreachable. Export additionally throws on non-ok responses so a
500 cannot slip past, and the clipboard is only written on success -- a failed
export can no longer masquerade as a backup via a stale paste.

Co-authored-by: MervinPraison <[email protected]>
2026-08-27 13:48:47 +00:00
praisonai-triage-agent[bot]andMervinPraison 5a69522ef0 fix: stop advertising MCP servers as launched in desktop app (fixes #4501)
The desktop settings registry described MCP servers as launched over stdio and offered an enable switch, but engine/server.py load_mcp() has no client consumer: _get_agent builds Agent(tools=_builtin_tools()) and never spawns a server. The text claimed a live capability that does not exist.

Apply the honest-text fix to all three synced registry copies and the Manage panel, and add a registry-drift guard asserting the hint no longer claims a launch the engine never performs.

Co-authored-by: MervinPraison <[email protected]>
2026-08-27 13:47:40 +00:00
praisonai-triage-agent[bot]andMervinPraison b3d0de957a fix: make Windows engine adoption reachable via separator/case-insensitive path compare (fixes #4499)
On Windows venv_python_rel() returned "Scripts/python.exe" (forward slash),
which Path::join appends verbatim, so the expected interpreter string never
matched the backslashed sys.executable the engine records in its lockfile.
adopt::decide byte-compared them, always returned DifferentInterpreter, and
every relaunch taskkill'd a healthy engine (and its trainer via /T).

- platform.rs: use "Scripts\\python.exe" so the produced path is canonical.
- adopt.rs: compare interpreter and venv_root through same_path(), which folds
  '/' to '\\' and lower-cases on Windows (case-insensitive FS) and compares
  as-is elsewhere.
- tests: adopt across mixed separator/case spellings; same_path semantics.

Co-authored-by: MervinPraison <[email protected]>
2026-08-27 13:43:25 +00:00
praisonai-triage-agent[bot]andMervinPraison 23e4312b1f fix: escape quotes in desktop markdown renderer (fixes #4494)
esc() covered only & < > , so a model-authored URL containing a double
quote closed the double-quoted href built in inline() and injected its
own attributes -- id=f made getElementById('f') return the injected <a>
instead of the composer <form>, breaking Enter-to-send. Adding the two
quote characters to the escape class is the whole fix, since inline()
escapes before the link rule runs.

Co-authored-by: MervinPraison <[email protected]>
2026-08-27 13:43:10 +00:00
praisonai-triage-agent[bot]andMervinPraison 77f88b8f23 fix(praisonai-ts): make Agent conversations restorable with typed getHistory/setHistory (fixes #4487)
getHistory() now returns the exported AgentMessage type carrying tool_calls and
tool_call_id, so persisting it is lossless. Adds a validating setHistory() that
rejects unknown roles, orphaned tool_call_ids and non-arrays, strips a leading
system message, and copies on write. The non-stream/no-tools start() path now
sends the full restored history via generateChat instead of dropping it.

Co-authored-by: MervinPraison <[email protected]>
2026-08-27 13:41:50 +00:00
praisonai-triage-agent[bot]andMervinPraison e6586c3805 fix: non-object JSON in chats/ no longer drops the connection (fixes #4496)
A file that is valid JSON but not an object (the app's own Export emits a
list) reached c.get(...) in list_chats() and raised AttributeError, which
escaped the (OSError, ValueError) handler and closed the connection with no
status -- so /chats, /projects and /search appeared as an engine outage.
Raise ValueError for a non-dict so it surfaces as a corrupt row instead.

Co-authored-by: MervinPraison <[email protected]>
2026-08-27 13:41:36 +00:00
MervinPraison dbbcecf2d6 feat(mobile): tools are visible, now that upstream announces them
praisonai-ts gained tool_call and tool_result, so the engine's
capabilities.tools goes true and three conformance scenarios move from
"cannot be produced" to produced and passing.

The flag was false for a real reason rather than caution: upstream
EXECUTED tools and never announced them, so a UI rendering rows from a
true flag would have rendered nothing and looked broken. The flag and
the behaviour agree again, which is the only thing the flag ever
claimed.

This also fixes a break the widening would otherwise have caused. The
mapping loop ended in an `else` that treated any unrecognised variant as
a terminal error -- so the first tool_call to arrive would have ended
the turn as a failure. Widening a union is source-breaking for exactly
this shape, and this is the consumer I predicted it would break.

Two things passed through rather than re-derived:
  - `ok` comes straight from upstream. Inferring it from a non-empty
    output is the defect the protocol comment was written against, and
    the tool_failed scenario deliberately carries a non-empty output on
    a failed call so a suite that inferred would fail it.
  - `seconds: null`, because upstream reports no duration. Null means
    unknown, which the view renders differently from zero.

Skipped conformance scenarios: 5 -> 2. The two that remain are the
approval prompts, which ApprovalManager gates upstream but cannot
surface on the event channel.

794 tests, boundaries clean, bundle unchanged at 42.4kB.
2026-08-27 14:41:13 +01:00
praisonai-triage-agent[bot]andMervinPraison 43742820e2 fix: persist desktop training state so a restart survives the live run (fixes #4492)
The training subsystem kept the live run and history in memory only, so any
engine restart lost the run: /train/runs returned empty, /train/stop had
nothing to stop, and start() launched a second trainer beside a live one --
the OOM that "one GPU runs one job" exists to refuse.

Each run now writes a run.json (state + pid) at spawn, on the RUNNING/STOPPING
transition, and on finish. Trainer.__init__ reloads from disk: a run whose pid
is still alive is adopted (current is set, so start still refuses and stop can
reach it via its pid's process group); an interrupted run is marked failed. The
engine stays stdlib-only.

Adds SurvivingARestart tests: a live run reappears, a second fine-tune is
refused, stop kills the adopted pid, an interrupted run reports failed, and a
finished run is not re-adopted.

Co-authored-by: MervinPraison <[email protected]>
2026-08-27 13:41:11 +00:00
praisonai-triage-agent[bot]andMervinPraison cc885c6689 fix: drain trainer pipe when log write fails (fixes #4493)
A single OSError on the log write abandoned the reader while the child
was still writing to a PIPE nobody drained. The child blocked on the full
pipe, proc.wait() blocked on the child, run.finish() never ran, and the
run read "running" forever -- refusing every subsequent run for the life
of the engine.

The reader now drains the pipe whatever happens to the log: a log-open or
log-write failure drops the log and keeps consuming stdout, so the run
still ends. Each line is also flushed, so train.log is the live record it
is documented to be rather than 0 bytes until exit.

Co-authored-by: MervinPraison <[email protected]>
2026-08-27 13:41:01 +00:00
praisonai-triage-agent[bot]andMervinPraison f9cdb9094c fix: persist launch_at_login's honest result, not the request (fixes #4498)
The /settings handler wrote the requested launch_at_login value first and
only attached set_launch_at_login's result to the response -- which nothing
reads. Off macOS (and in a macOS checkout) registration always returns
{"enabled": false}, so the toggle rendered on, persisted, and survived
restarts while no login item existed. Now the platform action runs first and
the persisted value is coerced to what actually happened.

Co-authored-by: MervinPraison <[email protected]>
2026-08-27 13:39:29 +00:00
praisonai-triage-agent[bot]andMervinPraison 83a28fcb4e fix: stop fetch_url following redirects past the approval gate (fixes #4490)
The desktop engine's fetch_url gated on the approved URL, then let urllib
follow any 3xx to any host -- including the engine on loopback -- under
that same approval, so an author-controlled redirect could pull another
chat's transcript, /settings or /logs into the model's context.

Fetch now uses a no-redirect opener that raises on any 3xx, so the honest
error reaches the model and the user can approve the new URL via a second
card. Adds a test standing up a 302 -> secret-body pair and asserting the
secret is not returned.

Co-authored-by: MervinPraison <[email protected]>
2026-08-27 13:38:29 +00:00
praisonai-triage-agent[bot]andMervin Praison 8a8b031565 fix: don't treat a lone declared tool_timeout as uniform (greptile P1)
When one agent declares tool_timeout and another omits it,
_resolve_uniform_tool_timeout classified the single value as uniform and
wrapped the shared tool dict, silently forcing the undeclared agent's
valid long-running calls to fail with ToolTimeoutError. Now a value is
uniform only when every agent/role declares it with the same value;
otherwise the per-agent resolver is the sole guard, leaving undeclared
agents unwrapped.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 13:38:13 +00:00
praisonai-triage-agent[bot]andMervinPraison b01d0721fc fix: invalidate cached OpenAI client when credentials change (fixes #4488)
Key the env-only getOpenAIClient() cache on API key + base URL instead of
mere existence, so a rotated/updated key or a changed base URL is picked up
without restarting the process. Add resetOpenAIClient() for tests and
settings-driven rebuilds. The identity string is never logged.

Co-authored-by: MervinPraison <[email protected]>
2026-08-27 13:37:11 +00:00
praisonai-triage-agent[bot]andMervin Praison 6b1a792ad7 fix(train): ship list-form and data_files local datasets to remote hosts
The remote dispatch fallback only recognised a dataset given as a bare
string, but the trainer's canonical shape is a list of mappings and it
loads any name/data_files that exists on disk. A config using
`dataset: [{name: ./local.json}]` therefore had its file left behind, and
the remote run failed on a path that existed only on the launching machine.

_local_dataset_in now inspects both forms, mirroring how the trainer
resolves a local file, and returns the first local dataset to ship.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 13:35:31 +00:00
MervinPraisonandClaude Opus 5 1e877e5feb test(train): the fake spoke a status vocabulary the runner does not
The triage bot fixed a real bug of mine: the dispatch compared
`state == "failed"` while status() returns `"failed (exit N)"`, so a remote run
that failed was reported as a success.

My test did not catch it because my fake returned exactly `"failed"` -- and
`"done"` for success, which the runner never returns either. The fake agreed
with the bug, so the test passed and the code was wrong. That is the same
defect as the code it was meant to check, one level up.

The fake now speaks the runner's actual vocabulary -- completed, running,
unknown, `failed (exit N)` -- and a test reads runner.py from disk and asserts
those spellings still exist, so the fake cannot quietly drift from the thing it
stands in for. Deleting `failed (exit` from the runner now fails the dispatch
tests.

Two test-harness repairs alongside it. `monkeypatch.setattr(train_cmd,
"import_code_module", ...)` never intercepted anything: train_llm imports that
name inside the function body, so the patch has to be on
praisonai_train._code_bridge, where the lookup happens. And the vocabulary test
read RemoteRunner through the module -- which the autouse fixture has already
replaced with the fake, so it would have asserted that the fake agrees with
itself.

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-27 13:35:31 +00:00
praisonai-triage-agent[bot]andMervin Praison f68fdd2a55 fix(train): correct remote status/dataset/dry-run and Windows CI skip
- Detect failed status by prefix: runner.status() returns 'failed (exit N)',
  so the equality check reported failed remote runs as success.
- Ship a dataset named only in --config when it is a local file, so the
  remote process is not handed a path that exists only locally.
- Resolve and validate the remote block in --dry-run so the preview matches
  the real dispatch and bad settings are caught before renting a GPU.
- Reject falsey non-mapping remote values ([], '', false) instead of quietly
  treating them as train-locally.
- Assert typer.Exit in the no-remote dispatch test rather than swallowing
  every exception; skip the POSIX-only keyring tests on Windows.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 13:35:31 +00:00
praisonai-triage-agent[bot] 1d47ebcd45 Merge pull request #4466 from MervinPraison/claude/issue-4440-20260827-1118
fix: leave a startup breadcrumb so a silent desktop launch is diagnosable
2026-08-27 13:32:53 +00:00
praisonai-triage-agent[bot]andMervin Praison 7daeca0e70 fix: count actual deliveries with explicit counter (PR #4464 review)
Track DeliveryOutcome.DELIVERED via a dedicated _delivered_count instead of
inferring delivered_deliveries from success - undelivered. NOT_CONFIGURED and
SUPPRESSED runs are recorded successes but not deliveries, so the derived metric
over-reported them. Applied to sync + async schedulers and _build_stats /
get_stats_sync, with sync + async tests asserting zero delivered for
NOT_CONFIGURED and SUPPRESSED runs.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 13:32:11 +00:00
praisonai-triage-agent[bot]andMervin Praison 6826b27608 fix: make standalone scheduler delivery outcome truthful (fixes #4454)
Standalone AgentScheduler recorded a run as success even when delivery of
its result to the chat channel failed: _deliver_result discarded the
delivery bool, on_success fired, and a failed send was only a swallowed
logger.error.

Now _deliver_result returns a typed DeliveryOutcome
(NOT_CONFIGURED/SUPPRESSED/DELIVERED/UNDELIVERED). Both the sync and async
schedulers observe it: an undelivered result is counted, surfaced via the
existing core HookEvent.MESSAGE_UNDELIVERED hook, and routed to on_failure
instead of on_success. get_stats() now exposes delivered/undelivered
counts. Intentional silence stays a recorded non-outcome.

Reuses the existing MESSAGE_UNDELIVERED protocol seam - no new user-facing
knobs or infrastructure.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 13:31:09 +00:00
praisonai-triage-agent[bot] 1feb94fcd9 Merge pull request #4477 from MervinPraison/claude/issue-4449-20260827-1130
fix: close wrapper gaps — sandbox AutoGen code exec, enforce tool_timeout on ACP/LSP tools, per-agent tool_timeout
2026-08-27 13:30:31 +00:00
Mervin Praison a2636405f1 Merge pull request #4486 from MervinPraison/fix/merge-gate-dispatch-dedup
fix(ci): dedupe merge gate dispatches under CI fan-out
2026-08-27 14:26:09 +01:00
MervinPraison 425f2f13e9 docs: auto-update documentation parity trackers [skip ci] 2026-08-27 13:25:30 +00:00
MervinPraisonandCursor da1ec9b9df fix(ci): dedupe merge gate dispatches under CI fan-out
Serialize workflow_run auto-dispatch, skip dispatch when a gate is already active or the queue is saturated, and reuse the guard in pipeline label sync.

Co-authored-by: Cursor <[email protected]>
2026-08-27 14:25:27 +01:00
Mervin Praison 35aedc4952 Merge pull request #4483 from MervinPraison/fix/ts-model-routing
fix(ts): a bare claude- or gemini- model no longer routes to OpenAI
2026-08-27 14:24:51 +01:00
Mervin Praison bb10e7017b Merge pull request #4484 from MervinPraison/fix/ts-gate-the-shipped-artifact
fix(ts): the shipped praisonai/mobile entry was not browser-loadable
2026-08-27 14:24:38 +01:00
Mervin Praison d89d69d36c Merge pull request #4485 from MervinPraison/fix/ts-nonopenai-stream-tools
fix(ts): non-OpenAI providers dropped the text a model said before a tool
2026-08-27 14:24:28 +01:00
praisonai-triage-agent[bot] 6af01eee05 Merge pull request #4476 from MervinPraison/claude/issue-4454-20260827-1129
fix: truthful scheduled-delivery outcome accounting (#4454)
2026-08-27 13:15:33 +00:00
MervinPraison a628f9f986 fix(ts): non-OpenAI providers dropped the text a model said before a tool
The AI-SDK tool loop called generateText unconditionally, so on any
non-OpenAI provider `stream: true` silently had no effect once tools
were configured -- and the round's assistant text was DROPPED.

A model that says "Let me check." before calling a tool had that
sentence recorded into `messages` for its own context and never
delivered to the caller. The user watched a tool run with no explanation
of why. Losing no streaming is a downgrade; losing a sentence the model
actually produced is a wrong transcript.

StreamChunk already carries text AND toolCalls, and StreamTextOptions
extends GenerateTextOptions, so one shape covers both and the loop now
streams when streaming is on.

This makes non-OpenAI consistent with OpenAI rather than inventing
behaviour: the OpenAI path has always streamed pre-tool text through
streamChatWithTools. Every real provider implements streamText --
anthropic, google and the ai-sdk backend all do -- so no provider loses
a transport it had.

tools-cross-provider.test.ts needed updating, and the reason matters.
Its guarantee is in its name -- "tools survive on every non-OpenAI
provider" -- but it asserted on generateText specifically, and `stream`
defaults to true. It now asserts on whichever transport carried the
call, which keeps the guarantee without pinning the mechanism. Its two
multi-round cases are pinned to stream:false, because they are about the
LOOP across rounds and that keeps a real generateText case in the suite.
Their mock also had `streamText: jest.fn()`, which returns undefined and
throws on `for await` before reaching any assertion.

1120 tests pass, tsc clean.
2026-08-27 14:12:54 +01:00
praisonai-triage-agent[bot]andMervin Praison d82674ac22 fix(ts): forward per-agent apiKey to AI-SDK backend for bare claude-/gemini-
The routing fix now sends a bare claude-*/gemini- name to the AI-SDK
backend, but getBackend() never forwarded the constructor's apiKey and the
backend's key gate read only the provider env var. A caller who passed
apiKey (no env var) authenticated on the OpenAI path but was rejected on
the path their model actually takes.

- Agent stores apiKey/baseURL and forwards them via resolveBackend config
- resolveBackend maps the flat apiKey/baseUrl onto providers[providerId]
- validateProviderApiKey accepts an explicit key, gated after reading it
- 2 tests pin the forwarding and the key-gate behaviour

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 13:12:04 +00:00
praisonai-triage-agent[bot]andMervin Praison 9f0e100462 feat: add optional automation contract to skill frontmatter (fixes #4456)
Adds a protocol-only SkillAutomation descriptor (schedule/deliver/prompt)
parsed from SKILL.md metadata.praisonai.automation, so an installed skill
can declare when to run and where to deliver. Preserves nested metadata
mappings during parsing and exports SkillAutomation. Materialisation via
the wrapper scheduler's consent-first SuggestionEngine/DeliveryRouter is
left to consumers.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 13:08:56 +00:00
praisonai-triage-agent[bot]andMervin Praison 7f75ffb5c9 fix: address reviewer P1s — blocked-stream durable state, raised-tool failures, full token scoping
- chat_mixin: run input guardrail BEFORE begin_durable_run in _start_stream so a
  guardrail-blocked stream never opens/finalizes a durable run as "succeeded"
  (parity with chat()/achat()); remove the now-redundant check from
  _start_stream_impl.
- tool_execution: a tool that *raised* (flattened error dict tagged
  _praison_retryable) again surfaces as ToolExecutionError so the run fails,
  while a plain tool-*returned* {"error": ...} still falls through to the LLM.
  Restores test_tool_failure_not_relabelled without losing the PR's self-correct
  path for shell_tools/tavily_tools-style returned errors.
- agents: scope every token-report field to the team's agents — rebuild
  total_interactions and by_model from the per-interaction log, and add
  _scoped_recent_interactions so get_detailed_token_report no longer leaks
  another concurrent team's activity.

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

Co-Authored-By: Mervin Praison <[email protected]>
2026-08-27 13:07:40 +00:00
MervinPraison d79981a91d fix(ts): the SHIPPED mobile entry was not browser-loadable, and my gate missed it
Found by an agent auditing a stale P0 list. It is the most serious thing
in this session, and it is a hole in the gate I added two PRs ago.

`src/mobile.ts` passed the gate. `dist/esm/mobile.js` -- what
package.json actually resolves `praisonai/mobile` to -- failed to bundle
for a browser with TEN unresolved builtins:

    Could not resolve "module"  @ dist/esm/llm/backend-resolver.js:1
    Could not resolve "url"     @ dist/esm/llm/backend-resolver.js:2
    Could not resolve "path"    @ dist/esm/llm/backend-resolver.js:3
    ... and 7 more

ROOT CAUSE. scripts/esm-shim.js prepended a CJS banner -- static imports
of module, url and path -- to any emitted file whose source uses a bare
`require()`. Three first-party files on the mobile graph do, all of them
LAZY provider loads. The banner turned three lazy requires into three
STATIC Node imports: precisely the failure the gate exists to prevent.

WHY THE GATE MISSED IT. It checked TypeScript sources only, and the
banner does not exist until build:esm has run. So it passed, confidently
and repeatedly, on an input that is not what ships. A gate that reports
OK about the wrong artifact is worse than no gate, because it is
believed.

Two fixes, because either alone leaves something wrong:

  - The banner is browser-safe now. A guarded top-level await keeps Node
    behaviour identical and lets a bundler defer the import instead of
    failing on it; in a browser the catch installs a `require` that
    throws only if a lazy provider path is actually taken, which on a
    phone it is not.
  - The gate checks dist/esm too, and CI builds before gating. Only when
    dist/ exists, so the gate still runs on a fresh clone.

One test needed updating and it is worth saying why rather than burying
it: esm-shim-banner.test.ts pinned BANNER_MARKER to
`__praisonCreateRequire(import.meta.url)` -- an internal identifier, not
a behaviour -- so renaming it failed four tests that were not about
naming. The marker is now `const require =`: a banner that stopped
defining require would still fail them.

Verified in Node after the change: dist/esm/mobile.js loads and exports
Agent/getEnv/randomUUID, backend-resolver parses claude-3-5-sonnet-latest
to anthropic, and the lazy require path still resolves --
createProvider('openai') returns an OpenAIProvider.

1136 tests pass. All four entries -- both sources, both built -- report
loadable in a webview.
2026-08-27 14:07:38 +01:00
praisonai-triage-agent[bot]andMervin Praison 8b21ff8bf1 fix: recognize local channel as tokenless and prevent stdin shutdown hang
- Add "local" to the three gateway tokenless gates (config validation,
  start_channels, _start_single_channel) so a token-free terminal channel
  starts instead of being skipped and marked degraded (Greptile P1 #1).
- Run the blocking stdin readline on a dedicated daemon thread so a pending
  TTY read at shutdown can never join-block interpreter exit; cancellation
  resolves the awaiting coroutine promptly (Greptile P1 #2).
- Add regression tests: cancelled-read clean exit, daemon reader guard.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 13:03:36 +00:00
MervinPraison 6634dc1e15 docs: auto-update documentation parity trackers [skip ci] 2026-08-27 13:03:18 +00:00
MervinPraison 39ce438f14 fix(ts): a bare claude- or gemini- model no longer goes to OpenAI
Found while verifying a stale P0 list -- not on it, and worse than
anything that was.

The Agent constructor carried its own copy of the model-parsing rule,
and it defaulted EVERY slash-less name to OpenAI:

    const providerId = this.llm.includes('/') ? ... : 'openai';

So `new Agent({ llm: 'claude-3-5-sonnet-latest', tools: [...] })` made
one OpenAI call and zero Anthropic ones, sending OpenAI-format tools to
the OpenAI endpoint. The tools were not dropped -- the entire request
went to the wrong vendor, which surfaces as model-not-found, or as a
bill nobody can explain.

backend-resolver.parseModelString had always inferred anthropic from a
`claude-` prefix and google from `gemini-`. There were simply two rules
that disagreed and the constructor used the wrong one. Now there is one.

A custom baseURL is a deliberate exception: pointing at an
OpenAI-compatible proxy that serves claude-* is a real deployment, and
prefix inference would route it away from the endpoint the caller
explicitly asked for. An EXPLICIT anthropic/... prefix still wins over a
baseURL, because that is unambiguous.

Verified as a positive control: restoring the old parser fails the two
routing cases and nothing else.

1144 tests pass, tsc clean.
2026-08-27 14:02:35 +01:00
praisonai-triage-agent[bot] 19dcfab368 Merge pull request #4475 from MervinPraison/claude/issue-4456-20260827-1129
fix: add optional skill automation frontmatter contract
2026-08-27 13:02:30 +00:00
Mervin Praison b5585b690b Merge pull request #4482 from MervinPraison/chore/remove-legacy-agent-folder
chore: remove legacy root .agent/ HTML drafts
2026-08-27 14:01:46 +01:00
MervinPraisonandCursor 8c73c6429a chore: remove legacy root .agent/ HTML drafts
Drop unused mer.vin blog draft files from the monorepo root; they are not referenced by CI, docs, or package code.

Co-authored-by: Cursor <[email protected]>
2026-08-27 14:01:28 +01:00
praisonai-triage-agent[bot] ef912ff69f Merge pull request #4463 from MervinPraison/claude/issue-4455-20260827-1114
feat: add first-class local (terminal) channel to the gateway
2026-08-27 12:59:28 +00:00
praisonai-triage-agent[bot]andMervin Praison e7c9742182 test(desktop): skip POSIX-only keychain/secret-tool leaf tests on Windows
The LeafStoreDeletion suite fakes `security`/`secret-tool` with #!/bin/sh
scripts, which Windows cannot execute, so set() returned False and three
asserts failed on the windows-latest engine leg. Those stores are the macOS
keychain and Linux libsecret and do not exist on Windows; skip the class
there using the file's existing os.name=='nt' pattern.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 12:43:06 +00:00
Mervin Praison 462c0a9aaf Merge pull request #4460 from MervinPraison/claude/issue-4440-20260827-1114
fix: leave a startup breadcrumb so a silent desktop launch is diagnosable
2026-08-27 13:35:23 +01:00
praisonai-triage-agent[bot]andMervin Praison e3e482756d fix: clear stale tool_timeout wrapper state on generator reuse
_build_tools_dict set either _tool_timeout_wrap (uniform) or
_agent_tool_wrap_resolver (heterogeneous) but never cleared the
mutually-exclusive key. Reusing the same generator with a different
timeout layout leaked a prior run's closure into the adapters, applying
an earlier budget to current tools. Now both keys are written every call
(inactive one set to None). Adds a reuse regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 12:30:48 +00:00
praisonai-triage-agent[bot]andMervin Praison 330386e782 fix: wire reasoning-effort session lifecycle + live cached-LLM updates (#4452)
Address the two P1 gaps raised in review:
- Cached LLM stayed stale: the reasoning_effort setter now updates the
  already-materialized LLM instance (which _build_completion_params reads),
  not just _llm_init_params, so post-construction changes take effect.
- Session effort was serialized but never written/restored in production:
  apply_cli_session_continuity now persists reasoning_effort alongside the
  model, and `run` restores it on --continue/--session (mirroring #3685).

Also: thinking_budget setter now routes through the effort setter, and the
effort value is normalised to a canonical graded level so the persisted/
queried value is always provider-portable.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 12:28:13 +00:00
praisonai-triage-agent[bot]andMervin Praison 3e0676df72 fix: show setup UI when a restored Train view hides it on first run (fixes #4441)
The desktop restores the last-used view from localStorage synchronously at
startup. When that is Train, body.training is set and the stylesheet hides
async; when it resolves to setup-needed/failed, firstRun()/failWithSetup()
render the setup wizard or failure banner into #thread -- which is now hidden
behind the Train view. The title bar shows "setup needed" with nothing on
screen to act on: the reported Windows dead end.

Force the Chat view (showEngineGate) when the engine is not ready so the
deliberate Train choice returns once the engine is up. Engine and Rust
supervisor untouched.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 12:21:33 +00:00
praisonai-triage-agent[bot]andMervin Praison 12e70266c4 fix: record provider finish_reason on async post-tool reasoning path
The async reasoning_steps post-tool branch captured the completion but
never called _record_finish_reason, so a content-filter/refusal/length
cutoff returned after an async reasoning-enabled tool call was silently
collapsed into "completed" instead of surfacing the explicit provider
terminal reason. Mirrors the sync path which already records it.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 12:20:36 +00:00
praisonai-triage-agent[bot]andMervin Praison 8d913ea61d fix: preserve contextvars in async guardrail executor offloads
Wrap the two new guardrail run_in_executor offloads with
copy_context_to_callable so a custom guardrail sees the same
contextual state (trace emission, session context) as the sync path,
matching every other run_in_executor call site in the SDK.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 12:20:03 +00:00
MervinPraison 78d8f5127d docs: auto-update documentation parity trackers [skip ci] 2026-08-27 12:12:08 +00:00
praisonai-triage-agent[bot]andMervin Praison b67521ce18 fix: make local channel shutdown-safe and honest about delivery
- Run the blocking stdin readline() in a dedicated single-thread executor and
  tear it down with wait=False/cancel_futures on stop(), so cancelling start()
  during a pending read never blocks asyncio.run's event-loop teardown.
- Propagate stdout write failures from send_message() instead of swallowing
  them, so the shared DeliveryRouter records a real failure (returns False)
  rather than a phantom success.
- Correct the startup log: Ctrl-D exits; /stop cancels the current run.
- Add regression tests: write-failure propagation, router failure on broken
  stdout, and prompt shutdown while a stdin read is in flight.

Addresses CodeRabbit/Greptile review findings on PR #4463 (fixes #4455).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 12:12:01 +00:00
Mervin Praison 7289ef9dd2 Merge pull request #4480 from MervinPraison/fix/claude-kick-review-chain
fix(ci): stop Claude workflow failing on review-chain kick
2026-08-27 13:00:43 +01:00
MervinPraisonandCursor 629e7add5e fix(ci): stop Claude workflow failing on review-chain kick
Use the GitHub App token for post-issue CodeRabbit/Qodo kicks, treat kick failures as non-fatal, and recognise triage-bot kick comments for idempotency.

Co-authored-by: Cursor <[email protected]>
2026-08-27 12:59:28 +01:00
Mervin Praison b4917bfe54 Merge pull request #4457 from MervinPraison/fix/ts-mobile-consumable
Fix/ts mobile consumable
2026-08-27 12:59:24 +01:00
Mervin Praison 85068aa055 Merge pull request #4448 from dajiaohuang/fix/4441-keep-setup-visible
fix(desktop): keep first-run setup visible
2026-08-27 12:59:11 +01:00
Mervin Praison d1b531d382 Merge pull request #4447 from dajiaohuang/fix/4442-desktop-release-version
fix(desktop): sync release version before bundling
2026-08-27 12:58:56 +01:00
praisonai-triage-agent[bot]andMervin Praison b3802184bf fix: close wrapper gaps — sandbox AutoGen code exec, enforce tool_timeout on ACP/LSP tools, honour per-agent tool_timeout (fixes #4449)
- AutoGen: local code execution is now opt-in via config.autogen.code_execution
  (default off; Docker on when enabled), warns on use_docker=False, and
  human_input_mode defaults to TERMINATE instead of forced NEVER.
- PraisonAIAdapter: injected ACP/LSP centric tools carry the same per-run
  tool_timeout guard via a wrap threaded through cli_config.
- agents_generator: heterogeneous per-agent tool_timeout budgets are honoured
  per-agent (no silent tightest-wins downgrade); uniform values still wrap once.
- _config_builder / crewai / autogen adapters: thread the per-agent wrap
  resolver so every framework honours per-agent budgets (tools stay shared).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 11:37:12 +00:00
praisonai-triage-agent[bot]andMervin Praison d827b2c0d0 fix: truthful scheduled-delivery outcome accounting (fixes #4454)
A standalone scheduled run no longer reports success when channel delivery
of its result fails. `_deliver_result` now returns a typed `DeliveryOutcome`
(NOT_CONFIGURED / SUPPRESSED / DELIVERED / UNDELIVERED) instead of discarding
the delivery bool; the sync and async schedulers fold that outcome into their
accounting so an undelivered result fires `on_failure` (not `on_success`),
bumps an undelivered counter surfaced in `get_stats()`, and best-effort emits
the existing `HookEvent.MESSAGE_UNDELIVERED` seam. Intentional silence stays a
recorded non-outcome.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 11:35:31 +00:00
praisonai-triage-agent[bot]andMervin Praison 720430d765 fix: add optional skill automation frontmatter contract (fixes #4456)
Bridge installed skills to the scheduler by parsing an optional
metadata.praisonai.automation block (schedule/deliver/prompt) into a new
protocol-only SkillAutomation dataclass exposed via SkillProperties.automation.
Preserve nested metadata mappings during parsing so the namespaced block
survives; older parsers ignore it and skills without it get automation=None.
Materialisation stays in the wrapper's existing SuggestionEngine + DeliveryRouter.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 11:33:27 +00:00
praisonai-triage-agent[bot]andMervin Praison 5cd774cae1 fix(ts): emit tool_call before failing tool_result on malformed args
Malformed JSON or non-object arguments throw before the normal tool_call
emission, so the catch previously emitted an orphan tool_result with no
matching tool_call to pair by callId. Track emission and announce the call
in the catch when the parse/validation path rejected it early.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 11:33:18 +00:00
Mervin Praison 7f885a0b5c Merge pull request #4467 from MervinPraison/chore/remove-legacy-deploy-folder
chore: remove legacy root deploy/ redirect folder
2026-08-27 12:26:44 +01:00
praisonai-triage-agent[bot]andMervin Praison 20335cc541 fix: translate reasoning effort to provider-native control and persist per session (fixes #4452)
Make the graded reasoning-effort knob live and provider-portable:

- core: add thinking/effort.py resolving off|minimal|low|medium|high to each
  provider's native param (OpenAI/xAI reasoning_effort, Anthropic/Gemini
  extended-thinking budget; no-op for non-reasoning models).
- llm/llm.py: emit the translated param in _build_completion_params from a new
  reasoning_effort setting (with thinking_budget as a backward-compatible alias).
- agent/agent.py: accept reasoning_effort= and revive the previously-dead
  thinking_budget alias, threading both into the LLM request pipeline.
- session/store.py: persist reasoning_effort on the session record and restore it
  on resume via get_session_reasoning_effort, mirroring model-persist (#3685).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 11:26:09 +00:00
praisonai-triage-agent[bot]andMervin Praison b5da5ffeba fix: surface provider content-filter/refusal/length-truncation as explicit terminal outcomes (fixes #4453)
Core now inspects the provider finish_reason/refusal signal and records a
distinct, additive terminal reason (content_filtered | refused |
length_truncated) instead of collapsing provider-side blocks into a silent
empty "completed" or a generic "failed".

- run_outcome.py: extend TerminalReason + precedence additively; add a shared
  classify_finish_reason() helper and PROVIDER_BLOCK_REASONS.
- llm.py: add _record_finish_reason() and call it at the non-streaming response
  capture points on both the sync and async LiteLLM paths (only overrides a
  "completed" reason so max_steps stays sticky).
- chat_mixin.py: record the classification on the OpenAI-native path where the
  empty-content finish_reason/refusal is already detected; reset the agent-level
  reason per turn so a prior block never leaks.
- agent.py: last_stop_reason surfaces the agent-recorded provider block reason.
- execution_mixin.py: _outcome_for_result() threads the provider block reason
  into RunOutcome for return_outcome=True callers.
- run.py (wrapper): map the new reasons to exit code 2 + a human-readable
  message + a machine-readable reason in --output json, winning over a generic
  empty-result failure.

Backward-compatible: unknown/absent finish reasons behave exactly as today.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 11:26:08 +00:00
praisonai-triage-agent[bot]andMervin Praison c163792c85 fix: label startup breadcrumb by the process that writes it
Every launch recorded itself as primary at main() start while the guard
callback -- which runs in the existing primary -- recorded that primary's
pid as secondary, reversing both role/pid pairs (greptile p1). Record
secondary at main() start (honest before the guard decides, own pid) and
upgrade to primary from setup(), which only the first instance reaches.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 11:26:01 +00:00
praisonai-triage-agent[bot]andMervin Praison 34dd7887a9 fix: async guardrail offload + async tool circuit breaker (fixes #4281)
Two async-safety parity gaps where an async code path silently failed to
match its sync sibling's behaviour:

1. Async guardrail validation ran a blocking, synchronous LLM call from
   inside async methods, stalling the whole event loop (and every task
   under asyncio.gather). _aapply_guardrail_with_retry and arun_task now
   offload the sync guardrail chain via loop.run_in_executor, mirroring
   async_memory_mixin's _run_memory_in_thread pattern.

2. The async tool-execution path had no circuit breaker (only loop-guard
   parity was added by #4005), so a repeatedly-failing tool could be
   hammered every turn via achat()/async workflows and the dead
   circuit_open retry-skip never fired. _execute_tool_async_impl now
   routes the invocation through the same per-agent/per-tool
   CircuitBreaker.acall used by the sync path, counting error-dict results
   as failures and surfacing circuit_open=True when the breaker opens.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 11:23:54 +00:00
MervinPraisonandCursor 950608400e chore: remove legacy root deploy/ redirect folder
Deployment infra now lives under src/praisonai-deploy/infra/ and src/praisonai-bot/infra/; the monorepo-root deploy/README.md stub is unused.

Co-authored-by: Cursor <[email protected]>
2026-08-27 12:21:36 +01:00
praisonai-triage-agent[bot]andMervin Praison 0fa7b6544c feat: add first-class local (terminal) channel to the gateway (fixes #4455)
Add a built-in `local` channel that reads stdin / writes stdout, registered
exactly like the nine remote platforms. It is token-free and owner-trusted,
and joins the same BotSessionManager, identity resolver and DeliveryRouter as
every remote channel — so `BotOS(agent=agent, platforms=["local"])` lets you
converse with a gateway agent from your shell with zero setup, and
`deliver="local:local"` routes through the shared router for cross-channel
continuity.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 11:20:09 +00:00
praisonai-triage-agent[bot]andMervin Praison ed7839458b fix(ts): emit tool_call before every tool_result and validate parsed args
Move the tool_call emission to immediately after argument parsing and
before the function-existence and approval gates, so denied and
unregistered calls no longer produce an orphan tool_result with no
matching invocation to pair by callId. Also reject non-object JSON
arguments (null, array, scalar) so AgentEvent.args honours its declared
Record<string, unknown> contract. Adds a denied-call ordering regression.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 11:19:47 +00:00
praisonai-triage-agent[bot]andMervin Praison 1a677bf1c0 fix: stream input guardrail, tool error dicts, and per-team token scoping (fixes #4446)
- _start_stream_impl now runs input-guardrail validation like chat()/achat(),
  so start(stream=True)/iter_stream() no longer skip it
- a tool's own {"error": ...} return with no transient/denial marker flows back
  as a normal tool result instead of raising a fatal ToolExecutionError, letting
  the LLM self-correct
- PraisonAIAgents token summaries filter to the instance's own agent names,
  preventing concurrent instances from leaking each other's usage/cost

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 11:19:04 +00:00
praisonai-triage-agent[bot]andMervin Praison 5cd34a4ac0 fix: leave a startup breadcrumb so a silent desktop launch is diagnosable (fixes #4440)
The Windows first-run report was "double-click, nothing happens, no
window, no folder, no logs", read as a missing-AppData crash. But the
shell's window is eager and visible and AppData is only touched after
the webview exists, so an absent data dir cannot cause a pre-webview
exit. The exit 0 is the single-instance guard telling a secondary
launch to hand off to the primary (which is hidden to the tray on
close) and quit -- indistinguishable from the outside from a shell
that died.

Write one line to %TEMP%\PraisonAI-startup.log before the guard runs
and before the window is built, tagging each launch primary/secondary
with its pid. That log exists whether or not %APPDATA%\PraisonAI does,
which is the "no logs to inspect" gap the report named, and it tells a
handed-off secondary apart from a real crash. Pure path/format helpers
live in the core crate with tests; the write is best effort so a shell
never fails to start over a log it could not write.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-27 11:16:54 +00:00
Mervin Praison d4d65df880 Merge pull request #4458 from MervinPraison/fix-tool-turn-no-output
chore: remove praisonai-js-hub-intake skill from repo
2026-08-27 12:13:32 +01:00
MervinPraisonandCursor 4f00da943f chore: remove praisonai-js-hub-intake skill from repo
Move the skill to local ~/.cursor/skills so TS routing guidance stays personal rather than tracked in the monorepo.

Co-authored-by: Cursor <[email protected]>
2026-08-27 12:12:44 +01:00
Mervin Praison f9d390f9b5 Merge pull request #4450 from MervinPraison/fix/audit-gaps
feat(train): remote training with CLI, YAML and desktop parity — plus two fixes that did not fix anything
2026-08-27 12:07:25 +01:00
MervinPraison d778f10f4c docs: auto-update documentation parity trackers [skip ci] 2026-08-27 11:06:36 +00:00
Mervin Praison e767046b74 Merge pull request #4451 from MervinPraison/feat/ts-mobile-entry-and-tool-events
feat(ts): one uuid util, a praisonai/mobile entry, and tool events (tasks 1, 5, 7)
2026-08-27 12:06:01 +01:00
MervinPraisonandClaude Opus 5 747deba009 feat(train): train on a remote server, from the CLI, the YAML or the desktop
Fine-tuning needs CUDA. A Mac does not have it, and neither does most of the
hardware this desktop app runs on -- so "Start" in the Training tab offered
every one of those users a button that cannot finish. Remote training was not a
missing convenience; it is the only way the feature works for them.

The SSH runner already existed (`praisonai_train.remote`) and was reachable
only from `praisonai-train remote start`. The gap was that nothing else could
ask for it: there was no YAML key, no flag on the command people actually run,
and nothing in the desktop form.

## One fact, three ways of saying it

    remote:                        --remote-host gpubox        [Run on: A remote server]
      host: gpubox                 --remote-python /opt/py     [SSH host: gpubox]
      python: /opt/py              --remote-workdir ~/runs     [Remote python: /opt/py]
      workdir: ~/runs

All three are the same instruction and are declared once, in
`praisonai_train/remote/settings.py`. The flags are named from that table, the
YAML is validated against it, and a test asserts every key has a flag and the
CLI declares exactly those and no others -- because three surfaces over one set
of facts is precisely the arrangement that drifts apart.

Precedence is the one this CLI already uses everywhere: the file is the
baseline, a flag wins, and an absent flag does not erase a file value.

`llm` dispatches on the resolved config, before the heavy import. That ordering
matters: the reason to train elsewhere is that this machine has no torch and no
GPU, so requiring them locally would make the remote path unreachable from
exactly the machines that need it. The desktop needed no new execution path at
all -- it writes the config, and the config decides.

Four things the design had to get right, each with a test:

* The remote block is stripped from the shipped config. Left in, the far side
  reads its own config, finds a host, and dispatches again.
* A remote run does not write ./config.yaml into the directory the user
  happened to be standing in. That is the local dispatcher's contract, not a
  side effect training elsewhere should have.
* SIGINT and SIGTERM stop the run on the other machine. Without that the signal
  ends the tail and leaves a rented GPU training a job reported as cancelled --
  which is exactly how the desktop Stop button would lie.
* Credentials are refused, and refused *as credentials*: they are also unknown
  keys, and telling someone they misspelled `password` invites them to spell it
  correctly into a file that gets copied to another machine and printed by
  --dry-run.

Host, workdir and python are validated as plain names -- they reach a remote
shell, and `remote.host: "gpu; rm -rf /"` is not a typo.

## Also fixed

`train_llm`'s `dataset` parameter was never unwrapped, and `_v()` recognised
only `OptionInfo` while `dataset` is a `typer.Argument` and produces
`ArgumentInfo`. Called as a plain function -- which its own docstring says is
supported -- it arrived as an ArgumentInfo, which is truthy, and was written
into the resolved config. The YAML dump then raised RepresenterError.

## Tests

30 in praisonai-train (parity, precedence, refusals, dispatch), 24 in the
desktop training suite. Nothing contacts a remote machine: the runner is
replaced, and what is under test is the decision and what gets handed over.

Thirteen mutations, thirteen caught. Four survived the first pass and each was
a test that did not reach its case -- a credential test passing because the
unknown-key check fired first, a dispatch test that never went through the
command, and two default-value tests where the field's own markup already
supplied the default. One survivor was a genuinely equivalent mutation: `get()`
already trims, so the extra `.trim()` calls were removed rather than pinned.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-27 10:25:00 +01:00
dajiaohuang a839c1e97e fix(desktop): preserve pending view selection 2026-08-27 16:47:39 +08:00
MervinPraison 13b72e6bde feat(ts): announce tool activity on the event channel
Task 7. AgentEvent carried three variants -- text, finish, error -- so a
consumer of streamEvents could see prose and nothing else. praisonai-ts
executed tools perfectly well and never said so, which meant any UI
built on it had to infer tool activity from the model's own description
of it. Inference is exactly how a tool call that silently failed still
looks like a normal answer.

Two new variants, and `ok` is the point of both. A tool that FAILED with
a message is byte-identical to one that succeeded with a message, so
success can never be read off a non-empty output. All three paths emit
it: success, throw, and an approval-gate denial -- a denied tool did not
run, and rendering it as a success tells the user something happened
that did not.

callId pairs a result to its call. Matching by position holds only while
exactly one call is ever in flight and silently attributes the wrong
output the moment that stops being true.

Structured events share the SAME queue as the text, which is what keeps
a tool call in its true position. On a separate channel it could arrive
before the sentence that introduced it, and a reader cannot tell a
reordered transcript from a model that said things in that order.

ADDITIVE BY CONSTRUCTION. `start()` gained a fifth optional parameter,
so every existing caller passes four and behaves exactly as before.
`stream()` filters on type === 'text' and is unaffected -- asserted, not
assumed. A run with no tools emits no tool events, which is the pair
that stops a spurious emit from satisfying every other case.

Verified by mutation: reporting a failed tool as ok fails 1 case,
never emitting tool_call fails 3.

One test bug worth noting rather than hiding: two cases first failed
because I passed inline arrow functions as tools. An anonymous arrow
registers under the name "", so the mocked call never matched and was
rejected before any event -- which is correct behaviour, and a small
piece of evidence that these events fire only for tools that really run.

1110 tests pass, tsc clean, build emits 290 files, both entries still
report webview-loadable.
2026-08-27 09:47:21 +01:00
MervinPraison 1592cc8a27 feat(ts): a praisonai/mobile entry that is verified webview-loadable
Task 5. `praisonai` resolves to src/index.ts, which re-exports the CLI,
the MCP server, the tool registry and the knowledge store -- modules
that read the filesystem, spawn processes and open sockets. A phone
loads none of them, and a bundler cannot tell: it follows every
re-export, pulls the whole graph in, and the build dies on a Node
builtin nothing was ever going to call.

src/mobile.ts is an allowlist rather than a convenience. Everything it
names is checked by scripts/webview-gate.mjs, which now gates both
entries and fails the build if either gains an import-time Node
dependency. The rule for adding to it is written in the file: if it
cannot run in a browser it does not belong there, even if it would be
useful -- a consumer reaching for something absent gets a resolution
error at build time, which beats a blank screen on a device at import
time.

  praisonai/mobile, minified: 77.8kB   (target was <400kB)
  praisonai, root entry:       2.97MB

The export is placed BEFORE './*' in the map so it wins the match.

TASK 2 IS A CORRECTION RATHER THAN A CHANGE. The list called node-fetch
a phantom dependency to delete. It is not a dependency at all -- it sits
in `overrides`, pinning a TRANSITIVE dep's version, most likely for a
security or compatibility fix. Removing it would change resolution for
whatever pulls node-fetch in, which is the opposite of harmless. Left
alone, and recorded here so it is not "fixed" later by someone reading
the same list. dotenv is genuinely gone: absent from every dependency
section and referenced nowhere in src.

Nothing was removed from package.json; the diff is purely additive.
1103 tests pass, tsc clean, and the build emits dist/mobile.js,
dist/esm/mobile.js and dist/mobile.d.ts.
2026-08-27 09:47:21 +01:00
MervinPraison 39ed151ee6 refactor(ts): one uuid util, so the webview fix covers the whole package
Task 1 of the mobile-consumable list. PR #4438 replaced the Node
`crypto` import in the two files on the Agent graph and left the
identical import in 32 others -- every one of which re-breaks a webview
bundle the moment anything reachable from it is touched. All 32 were
byte-identical: `import { randomUUID } from 'crypto'`.

They now import src/utils/uuid, and the two local copies #4438 left
behind are gone with them, so there is one implementation rather than
three drifting ones.

The tests force the fallback, which is otherwise unreachable:
globalThis.crypto.randomUUID exists in every environment the suite runs
in, so that path would stay green however broken it was -- and it is the
path that exists for older webviews, where nobody is watching a console.

Verified by mutation, four for four:

  version nibble ORed without masking  -> 3 fail (0xff reads as version f)
  variant nibble ORed without masking  -> 1 fail
  hex byte not zero-padded             -> 4 fail
  never using the platform generator   -> 1 fail

Worth recording: the first mutation run reported the two mask mutations
as SURVIVING. They had not -- jest was serving a cached transform. With
--no-cache both fail. A mutation run against a cached transform is a
mutation run that proves nothing, and it reports the reassuring answer.

Existing behaviour unchanged: 1096 tests pass, tsc clean, cjs+esm build
produces 289 files, and the webview gate still reports the Agent entry
loadable.
2026-08-27 09:47:21 +01:00
dajiaohuang 0da43d5cd3 fix(desktop): block views until engine status resolves 2026-08-27 16:35:40 +08:00
Mervin Praison 4d5e08fb1e Merge pull request #4445 from MervinPraison/fix/ts-mobile-consumable
ci(ts): guard the webview fix so #4437 cannot regress silently
2026-08-27 09:32:03 +01:00
MervinPraisonandClaude Opus 5 fa129301a3 fix(desktop): two fixes from this branch that did not actually fix anything
An audit went looking for the pattern this codebase keeps producing -- a check
that something was attempted rather than that it worked -- and found two of my
own recent fixes were exactly that.

## The "no output" fix only worked when the stream was completely empty

_drain_tools() empties the queue and returns how many events it emitted. It is
called in two places: inside the streaming loop, and again on the failure path.
The in-loop call discarded its return value, so by the time the tally ran the
queue was already empty and tools_shown was zero. The no_answer branch never
fired, and the user got "the engine produced no output" printed directly
beneath the tool cards it had just been shown.

One frame of anything is enough to take that path -- a reasoning frame, an
empty text chunk. The only case that worked was a generator that yielded
literally nothing, and that is the only case the tests covered:

    chunks=()                          -> no_answer   (tested)
    chunks=[reasoning frame]           -> empty       (not tested)
    chunks=[""]                        -> empty       (not tested)

The fix is one `tools_shown +=`. The tests now drive the loop body.

## Deleting a key still reported success

FallbackSecretStore was hardened so a delete has to succeed in every store the
secret could be read from. That layer is right, and it was only ever tested
against a fake -- so nobody looked underneath, where both real stores returned
True whatever happened.

Both had the same shape: the write path passes check=True, the delete path did
not. A locked keychain leaves the item intact and `security` exits 51; a Linux
session with no D-Bus cannot reach the collection at all. In both cases the app
said the key was removed, unset it for the running process so it looked gone,
and read it back on the next launch.

Exit 44 from `security` means "no such item", which is a delete that has
already happened, so it stays a success. `secret-tool clear` exits 0 whether or
not it matched, so any non-zero status is a real failure.

The tests put a fake `security` / `secret-tool` on PATH and exercise the real
KeychainSecretStore and SecretToolSecretStore. No keychain is touched.

Six mutations, six caught. Both of these fixes were mutation-tested when I
first wrote them -- against mutations of the layer I had just edited, which is
how a fix passes its own tests while the thing underneath it stays broken.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-27 09:03:10 +01:00
dajiaohuang 693188ed79 fix(desktop): preserve blocked view preference 2026-08-27 16:03:05 +08:00
dajiaohuang 106e4fdda8 fix(desktop): reject stale adopted engines 2026-08-27 15:43:29 +08:00
dajiaohuang aa83abd1e7 fix(desktop): restore views after engine boot 2026-08-27 15:35:32 +08:00
dajiaohuang de6dbb813b fix(desktop): expose and verify release versions 2026-08-27 15:33:14 +08:00
dajiaohuang 46b7ae9bad fix(desktop): keep first-run setup visible 2026-08-27 15:25:56 +08:00
dajiaohuang 707e11ba86 fix(desktop): sync release version before bundling 2026-08-27 15:18:49 +08:00
MervinPraison adc267afb1 ci(ts): guard the webview fix so #4437 cannot regress silently
PR #4438 made praisonai-ts loadable in a webview by removing the static
`crypto` and `events` imports from the Agent graph. Nothing stops them
coming back.

That gap is structural, not an oversight. `npm test` runs in Node, where
every one of these imports works perfectly -- so no unit test can catch
the regression. A single `import { x } from "fs"` added to any file the
Agent happens to reach would silently un-ship the mobile app, and the
first signal would be a blank screen on a device with no console.

The gate bundles src/agent/simple.ts for a browser target and fails on
any Node builtin reachable at IMPORT time.

STATIC vs DYNAMIC is distinguished deliberately. A static import is
evaluated at module load and kills the bundle outright; a dynamic
`await import()` inside a function only fails if that function is called
-- readline is reached only from createCLIApprovalPrompt, which is
CLI-only by name and contract. Failing on it would force a shim for a
code path a phone cannot take, which is how a gate gets worked around
rather than fixed. Lazy builtins are still printed on every run.

Verified as a positive control: re-adding `import { randomUUID } from
'crypto'` to simple.ts fails the gate with the builtin named and #4437
cited. Without that check this would be a gate whose state on real code
nobody knows.

NOTHING RUNTIME CHANGED. The diff is one new script, one new workflow,
and two lines of package.json. src/ is untouched, and the existing suite
is unaffected: 1096 tests pass, tsc clean, and the cjs+esm build still
produces its 288 files.

The workflow is path-filtered to praisonai-ts so it cannot affect any
other job.
2026-08-27 08:08:52 +01:00
MervinPraison 2081e5debc docs: auto-update feature parity trackers [skip ci] 2026-08-27 07:05:39 +00:00
Mervin Praison e4651bbc2c Merge pull request #4444 from MervinPraison/fix/tool-turn-no-output
fix(desktop): tool turns reported "no output", and reopened chats lost their history
2026-08-27 08:05:12 +01:00
Mervin Praison d9f28cfdea Merge pull request #4439 from MervinPraison/feat/praisonai-mobile
feat(mobile): praisonai-mobile — Tauri 2 shell, praisonai-ts engine, two swappable seams
2026-08-27 08:02:54 +01:00
Mervin Praison 37c73d92c5 Merge pull request #4443 from MervinPraison/fix/claude-triage-bug-auto-trigger
docs: align AGENTS.md with praisonai-ts review and triage routing
2026-08-27 08:02:04 +01:00
MervinPraisonandClaude Opus 5 79c0faf7ef fix(desktop): tool turns reported "no output", and reopened chats lost their history
Two bugs the user hit, with one thing in common: the app knew the answer and
did not use it.

## "No output" on a turn whose tools all ran

The desktop engine does not run the praisonaiagents in this repo. It runs the
one installed in the app's venv -- published 1.7.1 -- and in that version a
tool-using turn ends the stream without ever asking the model for its answer:

    _start_stream_impl in 1.7.1:  1 x chat.completions.create
    the same function in HEAD:    2 x chat.completions.create

So the generator yields nothing, `streamed` stays False (engine/server.py:1797,
set only in the text-delta branch at :1829), and the engine reports "the engine
produced no output" for a turn whose tools every one succeeded.

The library fix already existed here -- commit 06dfc8e59, 25 Aug -- and could
never reach anyone: pyproject.toml still declared 1.7.1, the same version
already on PyPI, and the desktop installs an unpinned `praisonaiagents` exactly
once, on first run. So the version is now 1.7.2 and the desktop asks for
`praisonaiagents>=1.7.2`. **This needs publishing to PyPI to reach users.**

Why it looked intermittent: whether the model answers in text or calls a tool
is a per-turn decision, and only the first shape streams. Why "Try again"
worked: the failed turn still left the tool result in the cached agent's
history, so the retry answered from it in plain text -- the failure fetched the
thing that made the next attempt succeed.

Three defensive changes so this cannot be silent again:

* The engine now drains tool activity on the failure path too. A turn that ran
  tools and then died showed neither the answer nor the tools -- the work
  happened and left no trace on screen.
* A turn that ran tools but produced no answer now says so, instead of
  reporting the same "no output" as a turn where nothing happened at all.
* The library raises instead of ending a tool turn in silence. Returning
  quietly there is indistinguishable from a model with nothing to say, which is
  precisely how this reached users.

And the dev escape hatch that was supposed to prevent exactly this class of
confusion did not work: engine/server.py computed the checkout path with a
fixed parents[2], which from the bundled copy under src-tauri/target/<profile>/
resolves to target/praisonai-agents -- a directory that does not exist. Its own
comment says it exists so that "a fix appear[ing] to have no effect" cannot
happen. It searches upward now, and honours PRAISONAI_AGENTS_SOURCE.

## A reopened chat had no history

`grep -c chat_history engine/server.py` returned 0. The conversation lived in
two places and only one of them was durable: the transcript on disk, which the
sidebar renders, and the agent's chat_history, which is what the model is shown.
Nothing ever copied the first into the second.

The agent cache is a plain dict in the engine process, so it is empty after any
restart; save_settings clears it outright; and the tools toggle keys a
*different* agent for the same session. After any of those, reopening a chat
showed the user their whole conversation while the model got a blank slate --
and said so: "I don't have access to your previous questions."

The transcript is now replayed into an agent that has none. Only when it has
none: an agent mid-session already holds those turns, including tool messages
the transcript never stored, and replaying over the top would duplicate them.
Blank turns are skipped -- an empty assistant message is what a failed turn
leaves behind, and feeding it back teaches the model that silence is an answer.

## Tests

engine/test_chat_stream.py is new: the chat path had no test at all, which is
why both of these shipped. Twelve tests, agent stubbed, no provider and no key
required. Eight mutations, eight caught -- including removing the replay,
reversing its order, replaying over an agent that already has history, and
restoring the original "no output" branch.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-27 08:01:53 +01:00
MervinPraison 207f05ad93 feat(mobile): accessibility and i18n, and two live bugs in my own main.ts
96 tests for the a11y and i18n layers, plus the fixes for what building
them exposed in the entry point I had just written.

THE LIVE REGION WAS THE SERIOUS ONE. I had put role="log" and
aria-live="polite" on the whole transcript element -- the same element
reconcile/applyOps mutates on EVERY publish. So a screen reader restarts
on each token batch and no sentence is ever finished, which is worse
than announcing nothing: the app becomes unusable rather than merely
opaque. Clearing it for a new chat announced the wipe, too.

Announcements now go through ui/src/a11y/announce.ts, into two small
regions that change only when there is something worth saying. That
policy coalesces to completed sentences, rate-limits at 1200ms, keeps a
character cursor so nothing repeats, flushes the tail when the turn
ends, never reads reasoning or tool arguments, and lets an approval
bypass the limit -- an approval blocks the run, so waiting politely for
the queue to drain waits for something that will not happen until the
user answers.

The second bug: the message textarea was labelled with the SEND button's
name, so the composer announced as "Send, edit text" -- telling a blind
user what the control beside them does and nothing about the one they
are in. It has its own key now, and the comment says why the two are
distinct.

.sr-only uses the clip-rect idiom rather than display:none, because
display:none and visibility:hidden remove an element from the
accessibility tree entirely -- a live region styled either way is a live
region nothing ever reads.

Also consolidates the duplicate string table I introduced in main.ts
onto ui/src/i18n/strings.ts, and routes dom.ts through it -- including
its hand-rolled `event`/`events` plural, which is wrong in most
languages: Polish has three plural categories, Welsh six, Arabic a zero.

One process note worth recording: my `git add -A` on a shared tree swept
four of the a11y agent's in-progress files into the previous commit,
whose message does not describe them. Content was unaffected.

794 tests, 119 files, bundle 42.4kB of 400kB.
2026-08-27 00:45:08 +01:00
praisonai-triage-agent[bot] 231d822d25 Merge pull request #4438 from MervinPraison/claude/issue-4437-20260826-2138
fix: remove Node builtins from Agent import graph for webview bundling
2026-08-26 23:35:10 +00:00
MervinPraison 5e7ce7e69d feat(mobile): an application, not a set of libraries
The audit's headline was "every layer is a library; nothing is an
application." This is the file that was missing.

tools/bundle.mjs has defaulted its entry to app/src/main.ts since it was
written, and that file did not exist -- so `npm run build` failed on a
missing file and the webview gate had NEVER run against the real import
graph. Every check was in a known state about fixtures and an unknown
state about the code that would actually ship.

It runs now, and it passes:

  bundle: 35.1kB of a 400kB budget, 0 external, 0 problems

tools/app-bundle.test.mjs keeps it that way, and asserts more than "it
built": one real symbol per layer must be present in the unminified
output, because a bundle that tree-shook the app away would pass every
other check -- and no testing/ fake may appear, because a scripted
engine reaching a shipped bundle means the app answers with a fixture on
a real device.

Also landing:

app/index.html carries viewport-fit=cover, which is not optional --
without it env(safe-area-inset-*) is 0px in WKWebView and every inset
collapses to flush, silently, because 0 is the documented safe collapse.

app/app.css uses NO viewport units at all. 100vh includes an address bar
that does not exist here and is wrong through every keyboard transition;
the layout comes from measured pixel geometry instead. The composer's
keyboard offset is padding rather than a transform, so the transcript's
scroll height shrinks with it and the last message stays reachable. The
textarea is 16px because iOS zooms the page when focusing anything
smaller.

app/src/platform.ts is the only file naming both shells, and the probe
is synchronous: an async "is there a native shell" answer would make the
first frame guess, paint wrong, and jump.

app/src/crash.ts is the last line of defence -- there was no onerror and
no unhandledrejection handler anywhere, so a throw in a click handler
left a UI that just stopped responding. It reports rather than swallows,
and draws once however many faults follow, because a crash cascades and
repainting each time pins the CPU.

Strings are injected rather than inlined, so gaining a language does not
touch this file.

698 tests, boundaries clean across 108 files.
2026-08-27 00:33:57 +01:00
MervinPraison e6281627dd feat(mobile): settings and composer views, and a def that knows its keychain slot
The settings screen and the text input, both as pure logic. 45 tests,
each load-bearing rule mutation-checked rather than trusted green.

Two design points worth keeping:

Secret rows are structurally value-less -- SecretRow has no value field
plus `readonly value?: never`, so writing one is a compile error, and a
@ts-expect-error test fails the typecheck if that protection is ever
removed. A runtime `"value" in row` check only proves one build behaved.

Presence has THREE states. hasSecret is async and buildSettings is not,
so an unresolved check renders as UNKNOWN rather than "Not set" --
telling someone their key is missing while it sits in the keychain makes
them paste it again.

Closes the gap that build raised: SettingDef could say "this is a
secret" without saying WHICH keychain slot, so a screen could render
presence and still not know where setSecret should write. The mapping
had to be carried in by whoever built the view, which put the one
identifier that addresses a stored credential outside the registry that
declares it.

secretRefOf refuses both mismatches instead of guessing: a secret def
with no ref has nowhere to write, and a non-secret def with a ref would
route an ordinary setting into the keychain where nothing reads it back.
Guessing a slot from the key name would store a real credential at an
address that is never read, and report success.

684 tests, boundaries clean across 98 files.
2026-08-27 00:30:02 +01:00
praisonai-triage-agent[bot] 61428b5cba Merge pull request #4433 from MervinPraison/fix/ts-mobile-runtime-gaps
fix(ts): read env through getEnv() so the Agent constructor works with no process global
2026-08-26 23:28:19 +00:00
MervinPraison b823000687 fix(mobile): the two shells read different safe-area variables
A live bug, found by the Tauri planning agent reading both adapters
side by side. The Tauri shell read --safe-area-inset-*; the web shell,
which I wrote, read --sai-*. On a real page one of them was always going
to find "", parse it to 0, and lay content flush against the notch.

It fails silently in the worst way: 0 is the documented safe collapse
for a missing inset, so there is no error, no warning, and nothing in
the console -- just content under the camera.

The conformance suite could not have caught it, and that is the more
interesting part. Both shells pass every shell-contract case because
each is driven through its own harness, so NEITHER ever reads real CSS.
A contract that exercises two implementations through fakes cannot see
them disagree about something only the real environment supplies.

So the names now live once, in the port, beside the type they describe.
There is also a test that gives each shell a stylesheet defining only
the shared names -- which is what a real page has -- and asserts both
read the same insets from it. Verified by mutation: changing one entry
fails two cases across two different shells.

Kept the env() spelling rather than the shorter one. A stylesheet line
that reads `--safe-area-inset-top: env(safe-area-inset-top, 0px)` is
self-evidently correct at a glance, which is worth more than four
characters.
2026-08-27 00:28:18 +01:00
MervinPraison 9a04071b22 feat(mobile): what a tap means, so an approval can actually be answered
dom.ts rendered approval buttons carrying data-approval-id and
data-choice, and nothing in the package listened for them. The prompt
was drawn, it blocked the run, and it could not be answered -- the run
then hangs until the engine's timeout with a correct-looking screen,
which is the worst class of bug here.

The mapping is a pure function over an attribute bag rather than logic
inside a listener, because the cases that matter cannot be tested well
any other way: a tap that lands on the label INSIDE a button (which is
what every real tap looks like -- the finger hits the text, not the
control), a tap on a control that went disabled mid-decision, an id that
went missing from a row.

It walks the ancestor chain outward, which is what every real toolkit
does and what an event.target check gets wrong on exactly the taps users
make most.

Three refusals are deliberate and each is tested with its pair:
  - a disabled control does nothing, and a disabled ANCESTOR blocks a
    tap on its child -- otherwise the walk finds something actionable
    further out, which is worse than doing nothing.
  - an unrecognised choice is refused rather than coerced. The run stays
    blocked either way, but a refusal is visible.
  - an unknown action stops the walk instead of resolving to the
    enclosing row's action, which is how a tap on an unfamiliar control
    would delete a chat.

Verified by mutation, three for three.

634 tests.
2026-08-27 00:26:09 +01:00
MervinPraison d69ef09c62 feat(mobile): the registries, so the app can offer an engine and a setting
The audit's sharpest structural point: every port, conformance suite and
view model existed, and nothing declared what the app actually OFFERS.
SettingDef[] was an injected parameter only a test ever supplied, and no
code path constructed a real engine, so no live answer was possible.

Both registries live in app/ because they are the only place that names
a concrete engine or a concrete setting -- which is exactly what keeps
every layer below unable to tell one engine from another.

An engine whose prerequisites are absent is OMITTED rather than offered
and then failed: a picker listing a choice that cannot work is a support
ticket, and selectEngine already reports "no engine X; available: ..."
which is the honest message. So praisonai-ts appears only when a factory
is injected -- praisonai cannot be a dependency until it is bundleable
for a webview (#4437), and the remote engine needs nothing injected and
works today.

The tests assert against the REAL registry rather than a fixture, which
is the only way the "apiKey is secret" case means anything: a def that
forgot secret:true would put a live key in a plain settings file, and a
fixture cannot catch that.

Also: the base URL loses a trailing slash. `${baseUrl}/v1/run` against
"http://host/" produces a double slash, which some servers 404 and
others silently redirect -- losing the POST body.

618 tests.
2026-08-27 00:24:45 +01:00
MervinPraison e768979520 feat(mobile): chatId on the controller, and an accurate markIdle contract
The controller hardcoded chatId: "current", so every turn from every
conversation carried the same literal and an engine had no way to tell
them apart. Switching chats mid-stream is the case that matters: the
answer still in flight belongs to the chat it started in.

markIdle was flagged by the audit as violating its own documented
contract -- "a turn finished AND was persisted" -- because the
controller calls it unconditionally. Traced it rather than changing it,
and the code is right while the doc was imprecise:

  - persistence happens BEFORE `end` is emitted, so a successful turn is
    already on disk by the time the finally block runs.
  - a cancelled or errored turn is deliberately not persisted, and
    idling anyway is required, not a bug: the alternative is a queue
    that stays busy forever after one failure and deadlocks every prompt
    behind it.
  - nothing is at risk, because indices come from stored messages only,
    so the next turn numbers from what is really there --
    session.test.ts asserts exactly that.

Documented the reasoning where the next reader will hit it.
2026-08-27 00:23:26 +01:00
MervinPraison d72beb5826 feat(mobile): settings a screen can actually render and write to
An audit found the keychain port completely inert: SettingsFacade had
no way to enumerate keys, no change notification, and -- the part that
mattered -- no way to WRITE a secret at all. set() refuses any
secret-flagged def and setSecret was not on the facade, so there was no
reachable code path in the package that stored a key. A fresh install
had no way to become usable.

The facade now carries defs(), subscribe(), setSecret() and
clearSecret(). It still has no getter, and that asymmetry is the design:
a view can configure a key and prove one is present without ever being
able to fault the value into a render tree, a log, or a screenshot.

SettingDef gained label, help, section and choices, so a screen renders
a control from data instead of hard-coding the key list that already
exists as data -- the two drift the first time a setting is added.

subscribe fires only after an ACCEPTED write. Notifying on a refused one
makes a screen redraw a value the user did not manage to change, which
reads as the setting having been accepted. Secret writes notify too, or
a row keeps saying "not set" after a key is stored.

619 tests.
2026-08-27 00:21:47 +01:00
MervinPraison b33ab06311 feat(mobile): the session, so a conversation actually survives
ChatRepository was fully tested and called by NOTHING. Every turn
streamed, rendered, and evaporated. The hole was structural rather than
an oversight: TurnState is assistant-only, StoredChat has both roles,
and no layer owned the join -- docs/gaps.md #4.

createSession owns it, and it is what engines call to record a turn.
That placement is the point: end.userIndex is authoritative and cannot
be computed by a client, because a cancelled or errored turn stays on
screen and is never written, so screen position and disk position
diverge immediately. The indices now come from whatever actually did
the write, and null means the write failed -- which is what withholds
Fork and Delete rather than offering them against a message that is not
there.

The user's message is written when the turn SUCCEEDS, not when send was
pressed, so an abandoned turn cannot leave a dangling prompt with no
reply beneath it in a stored transcript.

A failed write also does not advance the in-memory chat, or the next
turn's indices are computed against messages that were never stored and
every index after it is wrong.

Verified by mutation, three for three: an off-by-one index fails 2
cases, keeping a failed write fails 10, dropping the save fails 4.

596 tests.
2026-08-27 00:19:35 +01:00
MervinPraison c203793bdb feat(mobile): a keyed renderer diff, and a web shell that runs the app in a browser
Two pieces the package needed before it could have a UI at all.

ui/src/render/reconcile.ts is the diff, and it is pure. A streaming
answer publishes many times a second; rebuilding the transcript each
time throws away every node, loses selection, and forces layout on the
whole list to repaint one character. The tests assert OPERATIONS rather
than outcomes, because a suite that only checks "the right rows are
present" passes just as well against the rebuild-everything renderer
this exists to prevent -- and one case measures it: 200 publishes of a
growing paragraph cost exactly 200 ops.

Verified by mutation. Always-update fails 4 cases; comparing raw
previous indices instead of surviving order fails 3 -- that second one
is the bug where inserting a row reports a move for everything below it.

The exhaustiveness check in signatureOf immediately caught two row kinds
I had missed (error, dropped), which is the whole reason it is written
as a never-assignment rather than a default case.

adapters/src/web/shell.ts makes "runs end-to-end in a browser, no
Tauri" true. It is honest about the platform: no haptics, share falls
back to the clipboard, and the keyboard comes from visualViewport --
the only browser API that reports a software keyboard, clamped at 0
because overscroll can make it briefly negative.

It reports all THREE lifecycle phases, from three different signals.
visibilitychange alone cannot express "inactive", and collapsing that
into "background" would flush and stop the render loop every time the
user pulled down a notification. The contract caught that; I had
collapsed it at first.

Back is wired to popstate, which is a browser's Back, with the same
last-registered-first stack every other shell uses.

All three shells -- fake, Tauri, web -- now pass one contract.

580 tests, boundaries clean across 85 files.
2026-08-27 00:17:53 +01:00
MervinPraison 12a23feb07 feat(mobile): the turn in render order, closing the largest known gap
docs/gaps.md #1. TurnState had text as one accumulated string and tools
as a parallel array, so a view could render all the prose and then all
the tools -- and nothing else. That is not how any chat UI shows a
multi-step turn, and it is not something a renderer can work around.

TurnState now carries blocks: the turn in the order it happened. A delta
extends the open text block or starts a new one; a tool_call appends a
tool block; a tool_result for a call never seen gets a position too, or
its row exists in tools and appears nowhere on screen -- the same silent
loss the reducer creates that row to avoid.

The invariant that stops two representations of one thing drifting is
asserted rather than hoped for: every text block concatenated reproduces
text exactly. Both directions verified by mutation -- every delta
opening its own block fails one case (and would be thousands of nodes
per answer), a tool_call adding no block fails three.

buildTranscript walks blocks now, and only the LAST text block streams:
an earlier one was closed by the tool call after it, and a caret on it
would claim two places are being written at once. Row ids are distinct
per block, because two rows sharing an id is how a keyed renderer paints
the second paragraph into the first one's node.

534 tests.
2026-08-27 00:12:35 +01:00
praisonai-triage-agent[bot] c3317ff791 Merge pull request #4436 from MervinPraison/claude/issue-4427-20260826-2118
fix(esm-shim): detect real CJS inside template-literal interpolations (follow-up to #4427)
2026-08-26 22:32:11 +00:00
MervinPraison 66690e048a ci(mobile): the parity check never needed praisonai-ts installed
The upstream-parity job failed twice. First on npm ci -- praisonai-ts
gitignores its lockfile. Then on npm install, which fails there with an
ERESOLVE peer conflict (bedrock-agentcore -> @strands-agents/sdk). The
package's dependencies simply do not install cleanly.

That turned out to be the wrong problem to solve. Every member the check
looks at -- streamEvents, lastStopReason and their types -- is declared
locally in simple.ts, so unresolved third-party imports degrade only
unrelated members. The check needs typescript, which THIS package has,
and nothing else.

Asserted rather than assumed, across all four combinations:

  deps=present  api=correct  -> passes
  deps=present  api=drifted  -> FAILS
  deps=absent   api=correct  -> passes
  deps=absent   api=drifted  -> FAILS   <- the one that matters

Without that last row this would have been a check that always passes
once the install is gone, which is worse than deleting it.

tsc now runs from praisonai-mobile and only errors AT the generated
parity file count as drift. An assignability failure always reports at
the assignment site, so nothing real is lost -- while TS2307 and TS7006
noise from praisonai-ts's own sources would otherwise fail every run.

The job is also now cheap: one cached npm ci instead of installing a
large dependency tree that could not install anyway.
2026-08-26 23:18:14 +01:00
MervinPraison 8b8d080e86 ci(mobile): npm install for praisonai-ts, which gitignores its lockfile
The upstream-parity job failed on its first run in 10s. praisonai-ts
deliberately gitignores package-lock.json (src/praisonai-ts/.gitignore:6),
so 'npm ci' fails outright -- it exists precisely to install FROM a
lockfile. 'npm install' is what the repo already uses for that package
elsewhere (claude.yml:345).

Also hardens the check itself. tsc has to resolve praisonai-ts's own
imports to typecheck its Agent, so with no node_modules it cannot run at
all. It now SKIPS loudly and names the command to fix it, rather than
failing -- reporting a missing install as upstream drift would send
someone looking in agent-api.ts for a problem that is not there.

Both paths verified by moving node_modules aside: absent -> skip with
the fix printed, exit 0; present -> the real check runs.

The two check jobs passed on their first run, on both node 22.18 and 24
-- so the floor declared in package.json is real rather than a guess.
2026-08-26 23:13:14 +01:00
MervinPraison 61ba50c436 ci(mobile): run the mobile suite, because nothing did
No workflow referenced praisonai-mobile, so its 523 tests would never
have run on a PR. That is not hypothetical here: praisonai-desktop's
suite existed and ran nowhere for months for exactly this reason, and
desktop.yml opens by saying so.

Four gates, catching different things:

  typecheck   erasableSyntaxOnly. node:test STRIPS types rather than
              compiling, so an enum or a constructor parameter property
              is a runtime parse error at import -- this turns that into
              a typecheck failure at the file that caused it.
  boundaries  the two seams. Only engines/src/praisonai-ts may import
              'praisonai', only adapters/src/tauri may import
              @tauri-apps. A rule enforced only by review stops being
              enforced.
  test        the suite.
  test:bundle the gate's OWN fixtures, which prove each check can fail.
              A build check that has only ever seen clean input is a
              check nobody knows the state of.

The bundle gate is the one a normal test run cannot do: node:test runs
in Node, where a 'crypto' import and a top-level process.env both work
and both kill a webview bundle at import time.

Node matrix is 22.18 (the floor declared in package.json -- tested
because a declared floor nobody runs is a guess; 22.18.0 is where type
stripping became unflagged) and 24 (the forward check).

upstream-parity is its own job so drift from the real Agent is visible
in the checks list rather than buried in another job's log.

All five steps verified locally against the real package.
2026-08-26 23:11:12 +01:00
MervinPraison f67545b1b3 test(ts): cover the two hand-rolled replacements this PR introduces
The fix is right, but it swaps two battle-tested Node implementations
for hand-written ones and adds no tests. Both replacements sit on paths
CI cannot reach on its own:

randomUUID's fallback is dead code in every environment these tests run
in, because globalThis.crypto.randomUUID always exists there -- so it
would stay green no matter how broken it was. It is also the path that
matters, since it exists for older webviews, where nobody is watching a
console. The version and variant nibbles are set by hand, and an
all-0xff and an all-0x00 source are the inputs that expose a bad mask.

SimpleEventEmitter's risky parts are not the obvious methods. They are
once-unwrapping inside off -- without it a caller's unsubscribe silently
never matches, and the listener keeps firing afterwards -- and mutation
during emit, where splicing a live array shifts every later index down
and skips the next listener while still reporting success.

Verified non-vacuous by mutation, four for four:

  emit iterates the live array (no slice copy)  -> 3 fail
  off compares by identity only                 -> 1 fail
  version nibble ORed without masking           -> 2 fail
  hex byte not zero-padded                      -> 3 fail

21 tests. Full suite 1082 passing, tsc clean.
2026-08-26 23:05:58 +01:00
MervinPraison 0215ba9f38 fix(mobile): the bundle gate was gitignored and never actually committed
.gitignore has `src/*/build/`, which is right for build OUTPUT and
wrong for build TOOLING. So the previous commit's message described a
bundle.mjs that existed only on my disk. Moved to tools/, next to the
boundary checker, which is tracked and is where it belonged anyway.

Also refines the gate itself, after verifying PR #4438 against it
exposed a limitation: it treated every Node builtin alike, so a lazy
import no mobile path can reach failed the build exactly like an
import-time one. That is the difference between a blank screen and a
feature that is merely unavailable -- readline is reached only from
createCLIApprovalPrompt, which is CLI-only by name and contract.
Failing on it would force a shim for a code path a phone cannot take,
which is how a gate gets worked around rather than fixed. Lazy builtins
are still printed every build. Static wins when both kinds appear.

Against the real praisonai-ts:
  main       builtins=[crypto, events, readline]  problems=1
  PR #4438   builtins=[readline]                  problems=0

523 tests, boundaries clean.
2026-08-26 23:01:48 +01:00
MervinPraison d776d4c305 feat(mobile): the composition root, with the protocol check at boot
Step 19. createApp() takes its adapters INJECTED rather than building
them, so the whole boot sequence runs under node:test against fakes. A
composition root that constructs its own dependencies is the one part of
an app that can never be tested, and it is also where ordering bugs live
-- every case in boot.test.ts is an ordering or a teardown question.

Ordering that is asserted rather than assumed: settings load before the
engine is built (the engine choice and its credentials come from there);
dispose unsubscribes from the shell BEFORE disposing the engine, or a
lifecycle event arriving mid-teardown calls stop() on a disposed engine.

app/src/engines.ts is the only file that knows more than one engine
exists. It returns a result rather than throwing -- an unusable engine
should land the user on a settings screen naming which and why, not on a
crash handler -- and it disposes an engine it rejects, since one left
holding a socket only ever shows up as a second, unrelated failure.

Two of my own tests had the wrong premise and the suite corrected me:
checkProtocol deliberately ACCEPTS a newer engine, because decode.ts
degrades an unrecognised event rather than dropping it, so a v3 client
against a v4 engine loses an affordance and not the conversation. The
real failures are too_old and unreadable, and there is now a pair
asserting the newer case is accepted.

Also lifts fakeTime out of controller.test.ts into testing/src/fake-time
-- it was private there, so any other test needing a TimePort either
rewrote it or reached for real timers, which is how a pacing suite goes
flaky on a loaded CI box.

519 tests, boundaries clean across 80 files.
2026-08-26 23:01:48 +01:00
MervinPraison 663c071f27 feat(mobile): the tauri shell, and three rules promoted into the port
Step 21. Adds adapters/src/tauri (bridge + shell) and a shell contract
run against BOTH the fake and the Tauri adapter so the two cannot drift.

Three things were living in one implementation and are now part of the
contract, because an adapter author reading only the port would ship
without them:

  - openExternal's scheme allowlist. In a webview a javascript: URL is
    script execution in the app's OWN origin with the user's session,
    and the URL routinely comes from a model, a tool result or a pasted
    message. The rule was in the Tauri shell only, so the fake accepted
    anything and the contract could not assert it. Now in core, obeyed
    by both, with an allowlist rather than a blocklist -- the dangerous
    set is open-ended and platform specific.
  - back handlers are a STACK, last registered first. This was a comment
    inside the fake; a second author using a Set has no defined order and
    ships a modal that never consumes the gesture.
  - insets and keyboard height must be updated BEFORE subscribers run,
    since every re-render re-reads the snapshot inside the callback.

keyboardHeightPx is new, and both agents building against this port
asked for it independently: a component mounting while the keyboard is
already up laid out at 0 until the next transition -- one wrong frame,
then a jump. Exactly the reasoning already written for insets.

Verified by mutation rather than assumed. A permissive allowlist fails
9 cases; notifying before snapshotting fails 3. One of my own tests was
vacuous and the mutation run caught it: an uppercase JAVASCRIPT: URL is
refused whether or not the comparison is case-insensitive, so the case
that matters is an uppercase HTTPS: still being OPENED.

504 tests, boundaries clean.
2026-08-26 23:01:48 +01:00
MervinPraison 22162223f7 fix(mobile): give ttft its unit, document versions/active, record the rest
Building the ui layer against core and protocol surfaced nine design
gaps. Two were cheap and are fixed here; the other seven are written
down in docs/gaps.md rather than fixed, because each needs a decision.

ttft sat next to a field called seconds with no unit in its name, so a
renderer had to guess between seconds and milliseconds -- 1000x in a
number the user reads. The TS field is ttftSeconds now; the wire key
stays ttft, translated in decode.ts, which is the one place that
translation is supposed to happen. The all-11-events round-trip test
covers it.

versions and active had no documented meaning, so the UI could not
render them and dropped them. Both documented, including why decode.ts
clamps active into [0, versions-1] instead of trusting it: an
out-of-range index selects nothing and the message renders blank.

484 tests.
2026-08-26 23:01:48 +01:00
MervinPraison 239c75cacd feat(mobile): the ui layer, as pure decisions rather than DOM
Step 18. Framework-free on purpose: every decision is a named exported
function returning a description of what to render, so the React Native
port reimplements the renderer and reuses all of this.

The load-bearing one is approval routing. decisionIdOf() is the only
sanctioned way to get the id that goes back on the wire, and the
two-approval test delivers the prompts in the OPPOSITE order to their
tool rows, so any index-zip crosses rm with curl. Verified by mutation:
returning row.callId instead of row.approvalId fails two tests.

Tool success comes only from tool_result.ok, never inferred from a
non-empty output; a call with no result renders unresolved, never
successful; seconds: null renders as unknown rather than zero.

Fork/Delete are withheld when end.userIndex is null -- and offered at
index 0, which is the falsy-check trap that would hide them on the
first message of every chat.

At the root route the back handler returns false so Android can exit; a
handler that always returns true traps the user inside the app.

79 tests, every 'must not' paired with the case that makes an
always-refuse implementation fail.
2026-08-26 23:01:47 +01:00
MervinPraison 60fbf94d86 fix(mobile): make the disposed flag mean something, drop the dead one
Both were written and never read, silenced with void. streamed was
genuinely dead and is gone. disposed now fails a run fast: a disposed
engine that still accepted runs would start a provider request cancel()
can no longer reach, since dispose() has already cleared the live map it
looks in -- so Stop would report false forever.
2026-08-26 23:01:47 +01:00
MervinPraison d9bea85144 feat(mobile): enforce the upstream coupling that agent-api.ts only asserted
engine.ts claimed 'a signature change upstream is a typecheck error here
rather than a runtime surprise on a device'. Nothing actually checked
that, so it was a comment. This makes it true.

The check is one assignment -- the real Agent to the structural
PraisonAgent -- and tsc names the diverged member when it fails.
Verified as a positive control: injecting a member the real Agent lacks
produces 'Property renamedUpstream is missing in type Agent'.

Kept out of npm test deliberately: it needs the sibling praisonai-ts
checkout, present in the monorepo and absent in a standalone clone. It
SKIPS loudly there rather than passing quietly, because a check that
silently stops covering anything is worse than no check.
2026-08-26 23:01:47 +01:00
MervinPraison 3de23243f8 feat(mobile): the webview bundle gate, and the fake shell
Step 20. build/bundle.mjs fails the build on three things node:test
cannot catch, because node:test runs in Node where all three work:

  - a Node builtin surviving into the bundle. Read off the esbuild
    metafile rather than grepped from the output, because a grep cannot
    tell an import from a string containing the same word -- there is a
    test for exactly that innocent case.
  - a TOP-LEVEL process.env read. Scoped to depth zero on purpose: a
    guarded read inside a function is how the codebase is expected to
    read env, and a check that failed on it would be unusable. Both
    directions are tested.
  - silent size growth, against a deliberately tight 400kB budget.

Pointed at the real praisonai-ts Agent it reports crypto, events and
readline -- two of them static and unconditional, so the failure is at
import time with no error boundary and a blank screen. Filed upstream as
issue #4437. Top-level process reads are now 0 there, which is #4425.

Also adds testing/src/fake-shell.ts: back-gesture handlers are an ARRAY
rather than a Set, because they are a stack and the most recently
registered gets first refusal -- a Set has no defined order, so a modal
would not reliably consume the gesture ahead of the route beneath it.

326 tests.
2026-08-26 23:01:47 +01:00
MervinPraison e4c5e15a07 feat(mobile): the praisonai-ts engine, against the seam rather than the package
Steps 15-16. Implements AgentEnginePort on top of praisonai-ts and runs
it through the same conformance suite as remote-http and the scripted
fake: 21/21, with 5 scenarios declared unsupported and printed.

The coupling is declared structurally in agent-api.ts rather than
imported from 'praisonai'. Three consequences: the tests need no
provider, no network and no key; the file IS the list of what swapping
frameworks would cost (one class, three methods, a three-variant union);
and a signature change upstream fails the typecheck here rather than a
device at runtime.

Capabilities are stated against what the engine can REPORT, not what it
does internally -- so tools is false even though praisonai-ts executes
tools, because it never announces them and a UI rendering rows from a
true flag would render nothing. Recorded in docs/gaps.md.

end.userIndex comes from an injected RunPersistence and is null when the
write failed, never a fabricated zero. Deriving it is what shifted every
subsequent Fork onto the wrong message on desktop.

The classifier test caught a real bug: /\bauthentication\b/ does not
match Anthropic's 'authentication_error' because _ is a word character,
so a bad key was classified internal and sent the user to 'try again'
instead of to settings.

311 tests, boundaries clean.
2026-08-26 23:01:47 +01:00
praisonai-triage-agent[bot]andMervin Praison 725ff20c64 fix: preserve once() cancellation via off() in SimpleEventEmitter (PR #4438)
Store the original listener on the once wrapper and match it in off()
so passing the original callback to off() removes the subscription,
mirroring Node EventEmitter semantics.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 21:49:09 +00:00
praisonai-triage-agent[bot]andMervinPraison 4af9ccac59 fix: remove Node builtins from Agent import graph for webview bundling (fixes #4437)
Replace the static `crypto` import in agent/simple.ts and the static
`events` (EventEmitter) import in ai/tool-approval.ts with browser-safe
internal implementations, so `Agent` can be bundled for browser/webview
targets without pulling Node builtins at import time.

- simple.ts: local `randomUUID()` backed by WebCrypto with a Node<19 fallback
- tool-approval.ts: `SimpleEventEmitter` (~20 lines) backing ApprovalManager,
  plus WebCrypto-safe `randomUUID()` in place of bare `crypto.randomUUID()`

Public API is unchanged; `readline` stays lazy (CLI-only).

Co-authored-by: MervinPraison <[email protected]>
2026-08-26 21:42:38 +00:00
praisonai-triage-agent[bot]andMervin Praison e56f18d67e fix(esm-shim): skip comments/regex when scanning template interpolations
The interpolation depth-scanner counted braces inside line/block comments
and regex character classes as structural, prematurely closing the ${...}
interpolation and hiding a genuine require()/__dirname that followed it,
so the emitted ESM omitted its createRequire banner (Greptile P1).

The scanner now skips line comments, block comments, and regex literals
(disambiguated from division via the previous significant char), so their
braces no longer affect depth. Adds regression tests for regex-with-brace,
comment-with-brace, and division-not-regex cases.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 21:41:11 +00:00
praisonai-triage-agent[bot]andMervin Praison 727f98eacc fix: esm-shim only banners real CJS usage, not the word require in strings (fixes #4427)
needsCjsBanner() tested /\brequire\b/ against raw file text, matching the
word require (and __dirname/__filename) inside string literals and comments.
24 of 288 emitted ESM files got a createRequire banner they did not need,
each gaining hard imports of module, url and path — e.g. openai.js bannered
solely by the error string "Zod schemas require zod-to-json-schema".

- Add stripStringsAndComments() to blank strings/templates/comments (newlines
  preserved) before detection.
- Match real require(...) call expressions and identifier-position
  __dirname/__filename (not obj.require).
- Run main() only via require.main === module and export helpers so tests
  assert on the actual emit transform.
- Add esm-shim-banner.test.ts (10 cases, both directions).

Bannered ESM files: 24 -> 9 (remaining all have genuine require/__filename).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 21:31:40 +00:00
MervinPraison 19636e2133 fix(ts): guard the default token sink so streaming works with no stdout
`start()` with no `onToken` defaulted to `process.stdout.write`, which
threw on the FIRST streamed token in a webview -- after the constructor
had already succeeded, so the constructor guard alone did not cover it.

Extracted as `writeTokenToStdout` rather than left inline, so the test
calls the real function instead of re-implementing the guard beside it.
Both directions are asserted: absent stdout must not throw, and a
present stdout must still be written to, so "never write" cannot pass.

Credit to #4435, which spotted this call site.
2026-08-26 22:29:19 +01:00
MervinPraison 722a8b453b fix(ts): read env through getEnv() so the Agent constructor works with no process global
A Tauri/React-Native webview has no `process`. Fifteen unguarded
`process.env` dereferences — five of them in the `Agent` constructor
itself — threw `ReferenceError: process is not defined` on the first
`new Agent(...)`, so the bundle died at import time on a device while
every Node test passed.

`getEnv()` already existed in llm/openaiClientOptions and already does
the `typeof process !== 'undefined'` guard; these call sites simply were
not using it. Routed all fifteen through it across six files.

The new test deletes `globalThis.process` rather than mocking `getEnv`,
so a newly added raw deref fails it too. Verified as a positive control:
against the pre-fix constructor all four cases fail with the
ReferenceError at simple.ts:244.

Closes #4425
2026-08-26 22:26:03 +01:00
Mervin Praison c4d07b0db6 Merge pull request #4430 from MervinPraison/claude/issue-4427-20260826-2110
fix: esm-shim only banners real CJS usage, not the word 'require' in strings
2026-08-26 22:15:33 +01:00
Mervin Praison ae60932ef0 Merge pull request #4429 from MervinPraison/claude/issue-4426-20260826-2109
fix: add turn-scoped abort signal to AgentStreamOptions
2026-08-26 22:15:23 +01:00
praisonai-triage-agent[bot]andMervinPraison a84d123f82 fix: esm-shim only banners real CJS usage, not the word 'require' in strings (fixes #4427)
needsCjsBanner() tested /\brequire\b/ against raw file text, matching the word
inside string literals and comments. 24/288 emitted ESM files gained a
createRequire banner plus hard imports of module/url/path they never needed;
the clearest case, llm/providers/openai.js, was banner-ed solely because of the
error string "Zod schemas require zod-to-json-schema" despite having no
require() call. This made the built ESM strictly worse to bundle than source.

Strip strings/comments before detection and match real CJS usage: require(...)
call expressions and __dirname/__filename/module in identifier positions.
Bannered files drop from 24 to 9 (all with genuine __filename/require).

Adds esm-shim-banner.test.ts asserting on the emitted transform output in both
directions: a string/comment/template "require" must NOT get the banner, while a
real require()/​__dirname/module.exports must.

Co-authored-by: MervinPraison <[email protected]>
2026-08-26 21:14:33 +00:00
praisonai-triage-agent[bot]andMervinPraison 74ab57fbe5 fix: add turn-scoped abort signal to AgentStreamOptions (fixes #4426)
Breaking out of a stream's for-await loop detached the token sink but
never aborted the request, so the provider kept generating and billing.

- Add signal?: AbortSignal to AgentStreamOptions and thread it into start()
- streamEvents owns an AbortController aborted in its finally, so break
  (the iterator's return()) stops the upstream request by default
- Set lastStopReason = 'cancelled' on abort, distinct from 'error'
- Pass abortSignal into the post-tool-loop structured-output generateChat

Co-authored-by: MervinPraison <[email protected]>
2026-08-26 21:14:32 +00:00
Mervin Praison 9f149a02c6 Merge pull request #4428 from MervinPraison/fix-codesign-gate-reads-dmg
fix(desktop): the signature gate looked for a bundle Tauri had already deleted
2026-08-26 22:11:07 +01:00
MervinPraisonandClaude Opus 5 8c3fb8ba16 fix(desktop): the signature gate looked for a bundle Tauri had already deleted
v4.7.3 shipped with no macOS downloads. Both Mac legs failed, and the gate
added one commit earlier is why -- it looked for the .app, and by the time it
runs there is no .app:

    Bundling PraisonAI.app (.../bundle/macos/PraisonAI.app)
     Signing with identity "-"
    Signing .../PraisonAI.app: replacing existing signature
    Bundling PraisonAI_0.1.0_aarch64.dmg
    Cleaning .../bundle/macos/PraisonAI.app        <- deleted here

So the same run proves two things: the signing fix works exactly as intended,
and the check written to guard it could never have passed.

It now mounts the DMG and verifies the bundle inside. That is also the better
check -- the DMG is the artifact people download, and the one that was broken.

Both directions verified locally against real disk images rather than argued:
the published v4.7.2 DMG fails the gate ("code has no resources but signature
indicates they must be present", Sealed Resources=none), and a properly ad-hoc
signed DMG passes it ("valid on disk", "satisfies its Designated Requirement",
Sealed Resources version=2 rules=13 files=7). A gate that only ever rejects is
not a gate; it needed the positive control as much as the negative one.

While testing, my own harness reported the broken DMG as passing. It piped
codesign to head and read ${PIPESTATUS[0]}, which zsh spells differently, so
the exit status was silently discarded. The workflow does not pipe that command
and was never affected -- but the near miss is worth recording, because a test
harness that swallows the status under test is the same defect as the one being
fixed, one level up.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-26 22:08:19 +01:00
MervinPraison 8d2ebfe3b0 docs: auto-update documentation parity trackers [skip ci] 2026-08-26 21:06:45 +00:00
Mervin Praison ebb15c3588 Merge pull request #4423 from MervinPraison/claude/issue-4375-20260826-2051
fix: fail closed on empty/timed-out stdio MCP servers
2026-08-26 22:06:01 +01:00
praisonai-triage-agent[bot]andMervin Praison d8f4fd4bcb fix: clean up runner and stop filters masking empty MCP servers (#4375)
- Stop the MCPToolRunner (best-effort join) before raising on init
  timeout/error so a blocked handshake does not leak a daemon thread and
  its stdio child process across construction retries.
- Only exempt the empty-tools fail-closed check when a filter actually
  removed tools: if the server itself advertised zero tools, raise even
  when allowed_tools/disabled_tools is set (a filter cannot be blamed for
  an already-empty server).
- Add regression tests for filter-plus-empty-server and filter-removes-all.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 21:04:56 +00:00
Mervin Praison 01fd3892a7 Merge pull request #4424 from MervinPraison/claude/issue-4374-20260826-2051
fix: route run <file>.yaml to agent runner, not async-jobs parser
2026-08-26 22:03:41 +01:00
Mervin Praison 1dece2ce1c Merge pull request #4422 from MervinPraison/claude/issue-4376-20260826-2051
fix: isolate Chroma default persist path and catch native panics
2026-08-26 22:03:25 +01:00
praisonai-triage-agent[bot]andMervin Praison 927d46d88b fix: prioritize async-job verbs over same-named cwd paths in run routing
Reserved job verbs (submit/status/result/cancel/list/stream) now win over
os.path.exists() so a stray file named after a verb can't steal the jobs
API. Explicit paths like ./submit still route to the YAML runner. Adds a
regression test for the collision case.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 21:03:02 +00:00
praisonai-triage-agent[bot]andMervin Praison feb2b797a4 fix: propagate control-flow signals and wire isolated Chroma default path
- Re-raise KeyboardInterrupt/SystemExit/GeneratorExit in the Chroma client
  init and directory-walk BaseException handlers so Ctrl+C / interpreter
  shutdown is no longer masked as a backend RuntimeError.
- Point Knowledge.config's default vector_store path at
  .praisonai/knowledge/chroma so the primary Agent flow actually uses the
  isolated per-project store (matches _default_chroma_path). User-supplied
  path still overrides.
- Add regression test for KeyboardInterrupt propagation.

Addresses Greptile & CodeRabbit review feedback on #4376.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 21:00:19 +00:00
MervinPraison e33aec3ca0 docs: auto-update api.md [skip ci] 2026-08-26 20:58:56 +00:00
Mervin Praison 71e4ff5a08 Merge pull request #4417 from MervinPraison/claude/issue-4388-20260826-1905
fix: thread AbortSignal through providers and Agent for cancellation
2026-08-26 21:57:57 +01:00
praisonai-triage-agent[bot]andMervin Praison cb54080b68 fix: propagate cancellation into Agent tool loop and harden ai-sdk signal handling
- Agent.processToolCalls now receives the resolved AbortSignal and refuses
  to invoke (side-effecting) tools once aborted; cancellation propagates out
  of the loop instead of being buried as a tool error result.
- ai-sdk backend: caller cancellation is terminal (non-retryable CANCELLED)
  instead of being misclassified as a retryable TIMEOUT and re-attempted.
- mergeSignals now returns cleanup that detaches fallback abort listeners in
  every request finally block, preventing MaxListenersExceededWarning on
  Node < 20 with a shared agent-level signal.
- OpenAIService.streamText forwards an optional AbortSignal to the client.
- Tests: Agent tool-loop cancellation (aborted vs. running).

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 20:57:04 +00:00
praisonai-triage-agent[bot]andMervin Praison 2111517b8c fix: route run <file>.yaml to agent runner, not async-jobs parser (fixes #4374)
The legacy dispatcher routed every `args.command == 'run'` to the async-jobs
API, whose argparse only accepts {submit,status,result,cancel,list,stream}. So
`praisonai run agents.yaml` (and the unified dispatcher's rewrite of
`praisonai agents.yaml` -> `run agents.yaml`) hit the jobs parser and failed
with `invalid choice` (exit 2) instead of running the team.

Detect a YAML/file first token and hand it to the modern Typer agent runner
(commands/run.py `run_main`), which already resolves YAML files via
`_is_yaml_file`. Genuine job verbs (`run submit`, etc.) still reach the jobs
API unchanged.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 20:56:12 +00:00
praisonai-triage-agent[bot]andMervin Praison 150a404767 fix: thread AbortSignal through providers and Agent for cancellation (fixes #4388)
Adds a cooperative cancellation path so a UI Stop button can actually stop
a run and stop token billing. AbortSignal is threaded end-to-end:

- signal?: AbortSignal on GenerateTextOptions/StreamTextOptions/GenerateObjectOptions
- OpenAI provider forwards it via create(..., { signal })
- Anthropic and Google providers forward it via fetch(..., { signal })
- AI-SDK backend MERGES the caller signal with its timeout controller
  (was overwriting it) so aborting either stops the request
- Tools registry honours the previously-dead ToolExecutionContext.signal
- OpenAIService + Agent.chat()/start() + SimpleAgentConfig.signal, with an
  explicit per-call signal taking precedence over the agent-level default
  (mirrors Python cancel_token > interrupt_controller)

Behavioural tests call the code and fail before the fix.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 20:55:42 +00:00
praisonai-triage-agent[bot]andMervin Praison 58363cdba4 fix: fail closed on empty/timed-out stdio MCP servers (fixes #4375)
A stdio MCP server that times out on init, errors on init, or lists zero
tools previously only printed a warning and returned an empty _tools list.
Agent(tools=mcp) then ran as a tool-less chat and the model hallucinated
answers (n_tools=0). Now MCP raises TimeoutError/RuntimeError instead of
handing back a usable-but-empty object.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 20:55:17 +00:00
praisonai-triage-agent[bot] fad13b9107 chore: remove stray knowledge.db test artifact 2026-08-26 20:53:48 +00:00
praisonai-triage-agent[bot]andMervin Praison f21a40978e fix: isolate Chroma default persist path and catch native panics (fixes #4376)
Default ChromaKnowledgeAdapter persist path was the cwd-relative string
"knowledge_db", which two projects/harnesses can share and which lets
Chroma's rust bindings panic (pyo3 PanicException, a BaseException) and
kill the process on Windows. Now defaults to an absolute per-project
.praisonai/knowledge/chroma, wraps PersistentClient in try/except
BaseException -> RuntimeError, and adds a BaseException guard to the
directory-walk indexer so a native panic no longer unwinds the process.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 20:53:43 +00:00
MervinPraisonandCursor b3b4a49d3a docs: align AGENTS.md with praisonai-ts review and triage routing
Document TypeScript SDK ownership in the monorepo, add PR review checklists,
and point issue triage at src/praisonai-ts/AGENTS.md so agents review TS changes correctly.

Co-authored-by: Cursor <[email protected]>
2026-08-26 21:53:33 +01:00
Mervin Praison 527552d2db Merge pull request #4421 from MervinPraison/fix/claude-triage-bug-auto-trigger
Auto-trigger Claude for all triaged bug reports
2026-08-26 21:52:57 +01:00
MervinPraisonandCursor 0f5336892e Auto-trigger Claude for all triaged bug reports, not only owner titles.
CONTRIBUTOR bug reports with # [BUG] in the body but plain titles (e.g. #4374)
were skipped because autoClaude required an actionable title prefix. Now any
bug label or [BUG] marker triggers Claude; enhancement still needs title prefix.

Co-authored-by: Cursor <[email protected]>
2026-08-26 21:52:45 +01:00
MervinPraison 0955acc46f docs: auto-update feature parity trackers [skip ci] 2026-08-26 20:47:46 +00:00
Mervin Praison b121b4e42e Merge pull request #4420 from MervinPraison/claude/issue-4386-20260826-1908
fix: unify streaming + tools loop in praisonai-ts (#4386)
2026-08-26 21:47:18 +01:00
praisonai-triage-agent[bot]andMervin Praison db2c38f151 fix: keep tool_calls paired and separate schema from tool rounds (#4386)
Two correctness fixes flagged by Greptile (P1) and CodeRabbit (Major) in the
unified streaming+tools loop:

1. Assistant/tool message pairing: when a response exceeded
   maxToolCallsPerTurn, the assistant message stored every declared tool_call
   but only the capped subset got tool result messages, causing the next
   OpenAI request to 400 on unresolved tool_call_ids. Now the assistant
   message lists only the executed subset.

2. response_format + tools mutual exclusivity: tool rounds no longer send the
   json_schema response_format (OpenAI treats them as exclusive). The schema is
   applied on a final tools-omitted request once the model stops calling tools.

Adds a behavioral test asserting the assistant tool_calls stay 1:1 with tool
results when the per-turn cap truncates.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 20:45:22 +00:00
praisonai-triage-agent[bot]andMervin Praison 0efedc0d35 fix: unify streaming + tools loop in praisonai-ts (fixes #4386)
Streaming and tools are no longer mutually exclusive. A streaming agent
can now call tools: text deltas, tool calls and tool results interleave
on one request per round-trip via a new OpenAIService.streamChatWithTools.

- Remove the `this.stream && !this.tools` gates; the tool loop streams
  when `stream` is set and blocks otherwise.
- Default maxIterations 5 -> 20 (Python ExecutionConfig.max_iter parity).
- Add maxToolCallsPerTurn (default 10, Python parity).
- Add Agent.lastStopReason ('completed' | 'max_steps' | 'cancelled' |
  'error') so a truncated run is distinguishable from a completed one.
- Tests call the code path: a streaming run with tools yields text AND
  executes a tool, plus exhaustion sets lastStopReason='max_steps'.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 20:45:05 +00:00
MervinPraison e426c993ed docs: auto-update api.md [skip ci] 2026-08-26 20:42:27 +00:00
Mervin Praison 6120b0d295 Merge pull request #4416 from MervinPraison/claude/issue-4390-20260826-1906
fix: remove module-scope side effects in praisonai-ts
2026-08-26 21:42:10 +01:00
Mervin Praison 94bad4452f Merge pull request #4414 from MervinPraison/claude/issue-4385-20260826-1904
fix: add Agent streaming API to praisonai-ts (fixes #4385)
2026-08-26 21:42:00 +01:00
praisonai-triage-agent[bot]andMervin Praison c362be1b82 fix: restore build after rebase — add tool name to approval-denial result
Resolved rebase onto main: kept main's new AI SDK backend branches
(tools + outputSchema via generateObject) and applied this PR's
stream->streamEnabled rename + emitToken sink. Added the required `name`
field to the approval-denial tool result so processToolCalls's return
type is satisfied (tsc clean; agent + llm suites green).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 20:40:59 +00:00
MervinPraisonandClaude Opus 5 2c731ab13c chore: praisonai 4.7.3
The version the macOS signing fix ships in. v4.7.2's two DMGs cannot be opened
on any Mac -- the bundle was never signed, so the only signature on it was the
linker's, which declares a resource seal an unsigned bundle does not have, and
macOS reports that as "damaged" rather than as untrusted.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-26 21:38:27 +01:00
praisonai-triage-agent[bot]andMervin Praison 449640b36b fix: stream() yields finish text for non-streaming agents
Agent.stream() ignored the finish event, so agents running with
stream:false, tools, or an outputSchema (which emit only a finish event,
never token deltas) yielded no output. Now stream() surfaces finish.text
when no deltas were emitted. Adds a regression test and completes JSDoc
@param/@returns for the streaming APIs.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 20:37:26 +00:00
praisonai-triage-agent[bot]andMervin Praison 28f86c8eea fix: add Agent streaming API to praisonai-ts (fixes #4385)
Add Agent.stream() yielding AsyncIterable<string> (mirrors Python's
iter_stream, which yields plain str) plus Agent.streamEvents() for
structured AgentEvent deltas/finish/error. Remove both
process.stdout.write calls in start(); tokens now route through an
internal onToken sink that defaults to stdout only for CLI use, so
non-terminal hosts (browser, webview, React Native, server) can render
answers as they arrive. Cancellation is a single path: breaking the loop
detaches the sink.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 20:37:26 +00:00
praisonai-triage-agent[bot]andMervin Praison e9a8266514 fix(praisonai-ts): sync pnpm-lock.yaml after removing dotenv/node-fetch
Regenerate the lockfile importer to match package.json so
`pnpm install --frozen-lockfile` (CI) no longer fails with an
outdated-lockfile error. Removes the dotenv/node-fetch direct-dependency
entries and the now-unused dotenv package/snapshot; node-fetch remains as
a transitive dependency. Addresses Greptile P1 review feedback.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 20:37:25 +00:00
praisonai-triage-agent[bot]andMervin Praison 36c29e172a fix: remove module-scope side effects in praisonai-ts (fixes #4390)
Importing the package no longer reads process.env or runs dotenv.config()
at module-initialisation time, so it does not throw in runtimes where
`process` is undefined (webviews/bundles).

- logger.ts: lazy, `typeof process`-guarded `level` getter
- openai.ts: drop module-scope `dotenv.config()` and the dotenv import
- package.json: remove unused `dotenv` and `node-fetch` dependencies
- add behavioral tests that call the code (guarded level read; import
  without throwing / no dotenv)

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 20:37:25 +00:00
Mervin Praison 7604e29f47 Merge pull request #4410 from MervinPraison/fix-macos-damaged-signature
fix(desktop): the macOS build shipped an invalid signature, so every Mac refused it
2026-08-26 21:36:23 +01:00
Mervin Praison c6a4d49df7 Merge pull request #4396 from MervinPraison/claude/issue-4384-20260826-1808
fix: merge duplicate _sanitize_metadata in ChromaDB memory adapters
2026-08-26 21:34:44 +01:00
praisonai-triage-agent[bot] f98a0caec1 Merge pull request #4419 from MervinPraison/claude/issue-4389-20260826-1905
fix: dual ESM+CJS build with exports map for praisonai-ts
2026-08-26 20:33:59 +00:00
praisonai-triage-agent[bot] bb93447f37 Merge pull request #4415 from MervinPraison/claude/issue-4387-20260826-1904
fix: make OpenAI SDK construct in browser-like runtimes
2026-08-26 20:21:41 +00:00
praisonai-triage-agent[bot] eb94f9cb11 Merge pull request #4413 from MervinPraison/claude/issue-4407-20260826-1901
fix: wire ApprovalManager into Agent tool path
2026-08-26 20:18:33 +00:00
praisonai-triage-agent[bot] 9b1711b4ca Merge pull request #4412 from MervinPraison/claude/issue-4405-20260826-1902
fix: wire tools through AI SDK backend on non-OpenAI providers (praisonai-ts)
2026-08-26 20:15:08 +00:00
praisonai-triage-agent[bot] 7746520df9 Merge pull request #4418 from MervinPraison/claude/issue-4409-20260826-1909
docs: record inverted TS routing — src/praisonai-ts is canonical, praisonai-js is npm mirror
2026-08-26 20:11:16 +00:00
praisonai-triage-agent[bot] 963c2adde6 Merge pull request #4411 from MervinPraison/claude/issue-4408-20260826-1901
fix: parity tracker discloses it measures exported names, not behaviour
2026-08-26 20:00:45 +00:00
praisonai-triage-agent[bot] 37c3c8ce00 Merge pull request #4402 from MervinPraison/claude/issue-4383-20260826-1812
fix: consolidate duplicate persistence coordinators
2026-08-26 19:58:58 +00:00
praisonai-triage-agent[bot] 7fb5e556e2 Merge pull request #4397 from MervinPraison/claude/issue-4391-20260826-1808
fix: document all 11 stream protocol v2 events and guard against drift
2026-08-26 19:58:55 +00:00
praisonai-triage-agent[bot]andMervin Praison ec1e67af10 fix: restore ESM subpath exports and repoint internal requires to CJS twin
Address reviewer findings on the dual ESM+CJS build:

- package.json: the restrictive exports map (only "." + "./package.json")
  broke documented deep imports (praisonai/ai, praisonai/tools,
  praisonai/integrations/*, praisonai/db/*) with ERR_PACKAGE_PATH_NOT_EXPORTED.
  Add "./*" file subpath, "./*/index" and explicit "./ai"/"./tools" dir
  entries mapping to both esm and cjs outputs.
- esm-shim.js: tsc leaves explicit relative require('./x') calls in the ESM
  output; under dist/esm (type:module) those throw ERR_REQUIRE_ESM on Node <22
  (engines: node >=18). Repoint every internal relative require at its mirrored
  CommonJS twin under dist/ (resolved from the twin dir so package.json and
  directory-index targets also resolve), so createRequire loads real CJS/JSON.
- dual-build.test.ts: always rebuild (npm run build) so tests never validate
  stale artifacts; add behavioural tests asserting no relative require resolves
  inside dist/esm and that documented deep-import subpaths load under native ESM.

npm test: 1008 passed, 87 skipped, 0 failed. verify:dist: cjs + esm OK.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 19:43:32 +00:00
praisonai-triage-agent[bot]andMervin Praison 7f968abd3b fix: guard OpenAI paths against missing process in browser runtimes
- Add getEnv() helper reading process.env only when defined, and route all
  OpenAI client apiKey lookups through it (openai.ts, providers/openai.ts) so
  browser/webview/React-Native hosts no longer throw ReferenceError.
- Guard top-level dotenv.config() behind a process check.
- Honour an explicit dangerouslyAllowBrowser:false even when the base options
  already carried the flag (strip it).
- Add JSDoc (@param/@returns/@example) to exported helpers.
- Add regression tests for getEnv and the explicit-false override.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 19:35:08 +00:00
praisonai-triage-agent[bot]andMervin Praison 5920ce9cc8 docs: add explicit --repo to Sync workflow command in AGENTS.md
Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 19:34:40 +00:00
praisonai-triage-agent[bot]andMervin Praison 1ce45a01d0 fix: make approval:true usable by default instead of stalling 5 min
The `approval: true` shorthand built a bare ApprovalManager with no handler,
so requestApproval() fell through to a respond()-awaiting Promise that nobody
resolves — blocking every tool call for the full 5-minute timeout before
denying, compounding across calls and exhausting maxIterations.

Wire the existing createCLIApprovalPrompt() as the default handler so the
shorthand prompts interactively (safe by default, hard to misuse). No new
deps or exports. Adds tests asserting a handler is wired and that denial
resolves without hitting the timeout.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 19:33:04 +00:00
praisonai-triage-agent[bot]andMervin Praison 850b8e9b75 fix: preserve tool names and honor outputSchema in AI SDK tool loop (#4405)
- processToolCalls now sets `name` on tool-role results so the AI SDK
  adapter emits a non-empty toolName; non-OpenAI providers previously
  rejected the follow-up request.
- When both tools and outputSchema are configured, the resolved final
  answer is produced via generateObject instead of dropping the schema,
  matching the OpenAI native path.
- Extend cross-provider tests: assert tool name propagation and the
  tools+outputSchema combination.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 19:29:50 +00:00
praisonai-triage-agent[bot]andMervin Praison edd142b46d test(desktop): pin the ad-hoc identity and the macOS scope of the signature gate
Two regression gaps flagged in review, both places where the release-signing
tests would keep passing after a future change that reintroduces the exact
failure they exist to catch:

- The signingIdentity assertion accepted any truthy value; only "-" is the
  ad-hoc identity that makes the shipped "damaged" bundle's signature valid.
  Any other value is a Developer ID with no keychain to satisfy it (codesign
  fails) or a typo. Pinned to "-".

- The verify-before-upload test checked command presence and ordering but not
  that the step is scoped to macOS. Flipping its `if` to skip macOS -- or
  dropping it so it runs on Linux where codesign is absent and the step is
  silently skipped -- passed every assertion. Now pins `if: runner.os == 'macOS'`
  on the codesign step.

Mutation-checked: a bogus identity and a Linux-scoped gate each fail exactly
one test; 15/15 green otherwise.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 19:17:44 +00:00
praisonai-triage-agent[bot]andMervin Praison 74ec3766e2 fix: dual ESM+CJS build with exports map for praisonai-ts (fixes #4389)
The published package was CommonJS-only with no exports map or module field,
so tsc downleveled every `await import()` into a `require()` that browser/RN
bundlers cannot resolve statically. Add a parallel ESM build plus an exports
map so dynamic import() survives as a lazy chunk.

- package.json: add `module` + `exports` (types/import/require/default);
  split build into build:cjs + build:esm; add verify:dist smoke check.
- tsconfig.esm.json: emit esnext modules + declarations to dist/esm.
- scripts/esm-shim.js: post-process dist/esm — rewrite extensionless relative
  specifiers to .js / /index.js, prepend a createRequire(import.meta.url)
  banner to files using CJS globals, write dist/esm/package.json {type:module}.
  Source files and the CJS build are untouched (backward-compatible require()).
- scripts/verify-dist.mjs: assert both entries load Agent (require + import).
- tests/unit/packaging/dual-build.test.ts: behavioural tests — assert emitted
  ESM preserves import() (not require()), specifiers are extensioned, and the
  ESM entry loads in a native Node child process and constructs an Agent.

Deferred (matches the issue; avoids green-lying): sideEffects:false,
mobile/node subpath allowlists, and a browser-platform CI gate all require the
separately-filed root-barrel split first.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 19:10:58 +00:00
praisonai-triage-agent[bot]andMervinPraison 8d93faa651 docs: record canonical TS source is src/praisonai-ts, praisonai-js is npm mirror (fixes #4409)
Co-authored-by: MervinPraison <[email protected]>
2026-08-26 19:10:56 +00:00
praisonai-triage-agent[bot]andMervin Praison 37fc11f582 fix: make OpenAI SDK construct in browser-like runtimes (fixes #4387)
The openai SDK throws in any browser-like runtime (webview, Electron
renderer, React Native, browser) unless dangerouslyAllowBrowser is
passed. praisonai-ts passed it at 0 of 7 construction sites.

Add a shared buildOpenAIClientOptions helper that auto-detects
browser-like runtimes (leaving Node behaviour unchanged) and supports
an injectable fetch so hosts can route egress through native code,
keeping the API key out of the JS heap. Wire all 7 sites through it.

Extend ProviderConfig with fetch/dangerouslyAllowBrowser and add
per-agent apiKey/baseURL to SimpleAgentConfig to mirror Python's
per-agent api_key/base_url. Includes a behavioral test that constructs
the real SDK.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 19:09:25 +00:00
praisonai-triage-agent[bot]andMervinPraison 779ac480e5 fix: wire ApprovalManager into Agent tool path (fixes #4407)
Adds an optional `approval?: boolean | ApprovalManager` to SimpleAgentConfig
and gates every tool call in Agent.processToolCalls before invocation. Denials
are fed back to the model as the tool result (course correction) instead of
aborting the run. Reuses the existing ai/tool-approval.ts machinery; binds the
decision to the tool_call id (correlation, never positional).

Co-authored-by: MervinPraison <[email protected]>
2026-08-26 19:06:34 +00:00
praisonai-triage-agent[bot]andMervinPraison 40710fb911 fix: wire tools and structured output through AI SDK backend on non-OpenAI providers (fixes #4405)
Non-OpenAI providers silently dropped tools/outputSchema and answered
without them. The AI SDK backend already supports generateText({ tools }),
streamText and generateObject; simple.ts just never passed them through.
Format tools once in a provider-agnostic shape and run the tool-call loop
through the backend, matching what litellm gives the Python SDK. Adds a
cross-provider regression test asserting tools survive on every provider.

Co-authored-by: MervinPraison <[email protected]>
2026-08-26 19:05:50 +00:00
praisonai-triage-agent[bot]andMervinPraison 36f7547d5a fix: parity tracker discloses it measures exported names, not behaviour (fixes #4408)
The parity tracker decides a capability exists by matching an exported
symbol name, so an exported-but-unreachable symbol (e.g. ApprovalCallback)
was reported as "DONE" while unusable. Rather than build a heavy
reachability/conformance subsystem, make the reports honest about what
they measure:

- Add a "What this measures / does NOT" note to generated PARITY.md
  (TypeScript and Rust): counts are exported names, include barrel
  re-exports, and are not a behaviour or reachability check.
- Relabel the markdown status from "DONE"/"TODO" to "exported"/"missing"
  so a symbol-presence check is not mistaken for "works".
- Document the same limitation in the TypeScript extractor docstring.
- Add a regression test asserting the disclaimer + relabel are present.

Co-authored-by: MervinPraison <[email protected]>
2026-08-26 19:04:36 +00:00
MervinPraisonandClaude Opus 5 55b9dd486d fix(desktop): the macOS build shipped an invalid signature, so every Mac refused it
v4.7.2's DMG fails to open on every Mac with *"PraisonAI is damaged and can't
be opened"*. This is not the ordinary unsigned-app warning, and the
instructions shipped next to it could not have worked.

Nothing signs the bundle. The only signature present is the one rustc's linker
puts on the bare executable:

    CodeDirectory flags=0x20002(adhoc,linker-signed)
    Sealed Resources=none

Its CodeDirectory declares a resource seal, and an unsigned bundle has none, so
verification fails outright:

    code has no resources but signature indicates they must be present

macOS reports an *invalid* signature as "damaged" and an *untrusted* one as
"unidentified developer". Only the second can be bypassed with right-click →
Open, which is what INSTALL.md told people to do.

`signingIdentity: "-"` makes Tauri codesign the bundle with an ad-hoc identity.
Verified against the shipped artifact: signing it turns

    code has no resources but signature indicates they must be present

into

    valid on disk
    satisfies its Designated Requirement

An ad-hoc identity is still not a Developer ID and Gatekeeper still refuses the
app on a double-click -- but it refuses it as untrusted rather than broken,
which is a door with a handle on it.

The release build now runs `codesign --verify --deep --strict` on the .app
before the artifact is attached, so this cannot ship again silently. Tests pin
the identity and the gate, including that the gate runs before the upload
rather than after.

INSTALL.md now distinguishes the two messages instead of conflating them, and
says plainly that v4.7.2 is affected.

Two notes on the tests. One assertion matched the phrase "codesign --verify"
inside the comment that explains the gate, so deleting the command changed
nothing -- comments are stripped now. And a second check for unsealed resources
was dropped rather than pinned: `--verify` already fails on the exact bundle
that shipped, so it was only a second way of saying the same thing, which is
why removing it broke no test.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-26 19:53:44 +01:00
praisonai-triage-agent[bot]andMervin Praison 7a930cd3a2 fix: consolidate duplicate persistence coordinators (fixes #4383)
Make PraisonAIDB the single owner of the agent lifecycle store write hooks
and have PersistenceOrchestrator delegate its store dispatch to it, removing
the duplicated sync/async store plumbing.

- db/adapter.py: add PraisonAIDB._from_stores() to build an adapter over
  already-constructed stores (no URL parsing); fix the stale docstring that
  falsely claimed it bridged to the orchestrator.
- persistence/orchestrator.py: lazily build an internal PraisonAIDB and
  delegate on_message/aon_message + aon_agent_end store dispatch to it, while
  keeping the orchestrator-only surface (ConversationMessage return contract,
  resume-aware on_agent_start, LRU session cache). Docstring updated.

Public exports, all backends, and the MemoryConfig(db=...) live path unchanged.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:53:42 +00:00
praisonai-triage-agent[bot]andMervin Praison 6beb52d807 fix: document all 11 stream protocol v2 events and guard against drift (fixes #4391)
The desktop engine's "stream protocol v2" comment listed 9 events but the
engine emits 11: approval_request (the human-in-the-loop gate) and
tool_drafting were live on the wire but absent from the spec. The start and
end payloads had also drifted.

- Rewrite the comment to list all 11 events with their real payloads.
- Add StreamProtocolVocabulary tests that parse the comment and every
  emit()/_emit_now() call site and assert the sets match both ways, so the
  vocabulary can no longer drift without failing CI.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:53:14 +00:00
praisonai-triage-agent[bot]andMervin Praison eca0aca6b1 fix: merge byte-identical _sanitize_metadata in ChromaDB memory adapters (fixes #4384)
Extract the duplicated ChromaDB metadata sanitisation into a single
module-level helper `sanitize_chroma_metadata` in memory/adapters/factories.py.
Both `Memory._sanitize_metadata` and `ChromaMemoryAdapter._sanitize_metadata`
now delegate to it. Public method names/signatures unchanged, behaviour
byte-for-byte preserved. The distinct core.py JSON-based variant is untouched.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:52:01 +00:00
MervinPraison b95d8564ba docs: auto-update documentation parity trackers [skip ci] 2026-08-26 18:46:38 +00:00
MervinPraison ed8c04f3df docs: auto-update feature parity trackers [skip ci] 2026-08-26 18:44:44 +00:00
Mervin Praison 51a87a6088 Merge pull request #4406 from MervinPraison/docs/ts-agents-issue-routing
Document TS issue routing in src/praisonai-ts/AGENTS.md
2026-08-26 19:42:24 +01:00
MervinPraisonandCursor 2d014f3522 Document TS issue routing and praisonai-js mirror sync in AGENTS.md.
Aligns src/praisonai-ts/AGENTS.md with CONTRIBUTING and GitHub Actions:
issues on PraisonAI, fixes here, monorepo → praisonai-js sync after merge.

Co-authored-by: Cursor <[email protected]>
2026-08-26 19:42:11 +01:00
Mervin Praison 0a47f1e34c Merge pull request #4404 from MervinPraison/fix/ts-monorepo-routing-cleanup
Sync to praisonai-js: monorepo → npm mirror
2026-08-26 19:40:44 +01:00
MervinPraisonandCursor 67d69d649e Add Sync to praisonai-js workflow (monorepo → npm mirror).
Canonical TS path stays src/praisonai-ts/; mirror sync direction is
monorepo to praisonai-js, not the reverse.

Co-authored-by: Cursor <[email protected]>
2026-08-26 19:40:12 +01:00
Mervin Praison fe8af71582 Merge pull request #4403 from MervinPraison/claude/issue-4377-20260826-1807
fix: scope skill auto-approval per-agent, detect routed Claude/Gemini providers, offload blocking knowledge/memory in async paths
2026-08-26 19:36:45 +01:00
Mervin Praison ccd0e6b5ae Merge pull request #4398 from MervinPraison/claude/issue-4381-20260826-1806
fix: add --image attachment to one-shot CLI run
2026-08-26 19:36:35 +01:00
Mervin Praison a3ee693ad9 Merge pull request #4401 from MervinPraison/claude/issue-4380-20260826-1806
fix: show rendered diff in approval prompt for file-editing tools
2026-08-26 19:35:26 +01:00
Mervin Praison 0d3958d13d Merge pull request #4400 from MervinPraison/claude/issue-4378-20260826-1806
fix: harden praisonai call/agent-invoke API surface (§1 llm override, §2 per-app state, §3 Twilio SSRF)
2026-08-26 19:35:06 +01:00
Mervin Praison 781bb9ce36 Merge pull request #4399 from MervinPraison/claude/issue-4386-20260826-1805
fix: make praisonai-ts tool-loop maxIterations configurable and exhaustion observable
2026-08-26 19:34:39 +01:00
praisonai-triage-agent[bot]andMervin Praison b12f7fd6d8 fix: make InMemoryAdapter thread-safe for concurrent async memory writes
Finding 3 offloads task/knowledge memory writes via asyncio.to_thread, so
parallel task callbacks can now hit the shared in-memory adapter on different
worker threads concurrently. The non-atomic _next_id increment + append +
eviction sequence could produce duplicate memory IDs or lost entries. Guard all
adapter mutations/reads with a re-entrant lock (negligible cost single-threaded).

Addresses Greptile P1 review on PR #4403.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:34:37 +00:00
Mervin Praison 451032964f Merge pull request #4395 from MervinPraison/claude/issue-4379-20260826-1806
fix: deep-merge global and project config instead of first-file-wins
2026-08-26 19:33:29 +01:00
Mervin Praison 3d353df3fa Merge pull request #4394 from MervinPraison/claude/issue-4383-20260826-1805
fix: consolidate duplicate persistence coordinators
2026-08-26 19:33:00 +01:00
Mervin Praison 515c083ea0 Merge pull request #4393 from MervinPraison/claude/issue-4391-20260826-1805
fix: document all 11 stream-protocol v2 events and guard against drift
2026-08-26 19:32:41 +01:00
Mervin Praison 8ef7fa9e71 Merge pull request #4392 from MervinPraison/claude/issue-4384-20260826-1805
fix: merge duplicate _sanitize_metadata in ChromaDB memory adapters
2026-08-26 19:32:30 +01:00
MervinPraisonandCursor 70c8665919 Fix TS routing: implement in monorepo src/praisonai-ts, not praisonai-js.
Remove praisonai-js from merge-gate FINAL prompt, Claude STEP 3-ALT, and
tsMirrorPathReasons block. Hub issues and fixes stay in the same repo.

Co-authored-by: Cursor <[email protected]>
2026-08-26 19:31:35 +01:00
praisonai-triage-agent[bot]andMervin Praison 3d5c92931e fix(desktop): make training-run liveness probe Windows-portable
The engine (windows-latest) CI job failed in test_training.py -- a file
outside this PR's stream-protocol scope -- because _alive() and one inline
assertion used os.kill(pid, 0). That POSIX liveness idiom is broken on
Windows: signal 0 maps to TerminateProcess (it kills rather than queries),
and a dead pid raises OSError(WinError 87) instead of ProcessLookupError.

Route the Windows check through OpenProcess + GetExitCodeProcess, which
only observes, and replace the os.kill-raises assertion with the same
portable _alive helper. POSIX behaviour is unchanged; no production code
touched.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:31:15 +00:00
praisonai-triage-agent[bot]andMervin Praison 886d17f808 fix: guard --image against silent-drop and comma-corruption paths
Reject --image up front on execution paths that cannot carry a per-invocation
attachment (--agent, --profile/--profile-deep, YAML file, --output actions)
instead of silently discarding it, and reject single --image values containing
a comma (which ImageHandler treats as the multi-image separator) so a valid
path/URL is never fragmented. Adds 5 CLI guard tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:31:10 +00:00
praisonai-triage-agent[bot]andMervin Praison 4f36861646 fix: align approval diff preview with edit_file/acp_edit_file semantics
- edit_file: honour replace_all (default False = first occurrence only) so
  the previewed diff matches what edit_file actually applies
- acp_edit_file: use its real whole-file contract (filepath + new_content)
  instead of requiring old_string/new_string, which never matched and fell
  back to the truncated argument summary
- add regression tests for first-only vs replace_all and ACP whole-file diff

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:29:53 +00:00
praisonai-triage-agent[bot]andMervin Praison ede4f711a3 fix: clear panel descriptor on llm override + structural media-URL validation
Address two P1 reviewer findings on PR #4400:

- agent_invoke._swap_llm: also clear _panel_descriptor (not just
  _llm_explicit) so Agent._apply_default_llm no longer early-returns for
  panel-configured agents. Previously an accepted agent_config.llm override
  silently kept running on the panel model. Mirrored in the manual fallback.
- call._validate_public_base: replace prefix-only check with urlparse-based
  validation. Reject malformed values with no host and cleartext ws:// to
  non-local hosts (which would expose live audio + the one-shot session token
  over an unencrypted transport). Localhost ws:// still permitted for dev.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:29:45 +00:00
praisonai-triage-agent[bot]andMervin Praison 2b86b20872 fix: align CLI config write target with merged precedence
_config_write_target() used _find_config_file() which only sees cwd +
global, so with a nearer ancestor config the CLI wrote a lower-precedence
global file that the ancestor overrode on reload. Use the highest-precedence
discovered file instead, and seed set_plugin_enabled() from the target file
only (not the merged view) to avoid persisting inherited global keys.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:22:34 +00:00
praisonai-triage-agent[bot]andMervin Praison 798b8280d5 fix(desktop): close drift-guard gap and Windows secret-delete flake
Strengthen the stream-protocol drift guard to also read the event names
returned by _classify_stream_item, the source of the variable forwarded
through emit(event, frame). A new classifier return value now fails the
guard instead of drifting onto the wire unseen (Greptile finding).

Skip test_a_delete_the_fallback_could_not_perform_is_not_reported_as_success
on Windows: its precondition (an unwritable store via chmod(0o500)) is
unreachable there because Windows ignores POSIX mode bits for directory
writability, matching the existing os.name == "nt" skips in this file.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:21:21 +00:00
praisonai-triage-agent[bot]andMervin Praison 58346d5318 fix: scope skill auto-approval per-agent, detect routed Claude/Gemini providers, offload blocking knowledge/memory in async paths (fixes #4377)
- Finding 1: key skill auto-approval by a unique per-instance
  _approval_scope_id instead of the display name (which defaults to
  "Agent" for every unnamed agent), preventing one agent's skill grant
  from leaking to unrelated agents in the same process.
- Finding 2: add a substring fallback in _detect_provider so routed
  models (bedrock/anthropic.*, vertex_ai/claude-*, openrouter/anthropic/*,
  vertex_ai/gemini-*) get the correct adapter and its streaming guard
  instead of falling through to the OpenAI adapter and crashing async
  streaming.
- Finding 3: offload the synchronous, network-bound knowledge.search and
  store_in_memory calls via asyncio.to_thread so they no longer block the
  shared event loop for concurrently running agents/tasks.

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

Co-Authored-By: Mervin Praison <[email protected]>
2026-08-26 18:17:08 +00:00
praisonai-triage-agent[bot]andMervin Praison 057394940f fix: show rendered diff in approval prompt for file-editing tools (fixes #4380)
Attach a unified diff to the ApprovalRequest context for edit_file,
write_file and apply_patch so the console approval prompt renders the
actual +/- change instead of a truncated argument dump. Non-edit tools
keep the existing argument summary. The diff also travels on the request
context so non-console backends can render or attach it.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:11:45 +00:00
praisonai-triage-agent[bot]andMervin Praison 7609339532 fix: harden praisonai call/agent-invoke API surface (fixes #4378)
- call.py §3: build Twilio media-stream URL from PRAISONAI_CALL_PUBLIC_BASE
  instead of the client-controlled request.url.hostname (Host header), closing
  the SSRF/call-audio-exfiltration path when CALL_SERVER_TOKEN leaks.
- call.py §2: move module-global `tools` + `_pending_stream_sessions` into a
  per-app, thread-locked CallAppState on app.state so co-hosted/multi-tenant
  build_call_app() instances never share tool schemas or cross-consume tokens.
- agent_invoke.py §1: route agent_config {"llm": ...} overrides through the
  SDK's Agent._apply_default_llm invalidator (with a manual-fallback) so the
  cached _llm_instance/_unified_dispatcher/_llm_init_params are dropped and the
  request actually runs on the requested model.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:11:30 +00:00
praisonai-triage-agent[bot]andMervin Praison ad37918324 fix: add --image attachment to one-shot CLI run (fixes #4381)
Add a repeatable --image option to `praisonai run` and plumb it to the
existing vision path instead of the hardcoded args.image = None. The core
Agent and the wrapper's handle_direct_prompt/ImageHandler already support
image input (local paths, http(s):// URLs, MIME detection, base64, multi-
image and clear validation errors); this closes the missing CLI surface.

Image runs bypass the warm-runtime fast path and the bare-Agent "actions"
path (neither can carry an attachment) so the vision-capable path handles
them. Text-only runs are unchanged.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:11:21 +00:00
praisonai-triage-agent[bot]andMervinPraison 8fae3f8708 fix: make tool-loop maxIterations configurable and exhaustion observable (fixes #4386)
The praisonai-ts tool-call loop had a hardcoded 5-iteration cap and, on
exhaustion, logged a warning and returned finalResponse — which is ''
when tool calls were still pending. That empty string is
indistinguishable from the model saying nothing.

- Add optional maxIterations config field (default 5).
- Throw on genuine exhaustion (loop still needed another round-trip)
  so callers can observe it instead of receiving ''.

Unifying streaming with the tool loop (interleaving text deltas and
tool calls on one stream) depends on the streaming Agent API in #4385
and is intentionally deferred to that work.

Co-authored-by: MervinPraison <[email protected]>
2026-08-26 18:11:12 +00:00
praisonai-triage-agent[bot]andMervin Praison 09e52d69ca fix: deep-merge global and project config instead of first-file-wins (fixes #4379)
Config resolution previously returned the first config file found, so any
project-local .praisonai/config.* silently shadowed the user-global config,
discarding every global default. Introduce _discover_config_files() (global
first, ancestor project dirs, nearest project last) and a recursive
_deep_merge() (dicts recurse, lists/scalars replace); _load_config() folds
them lowest->highest so a project config selectively overrides globals while
unset keys fall through. A single-file fast path preserves prior behaviour.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:09:47 +00:00
praisonai-triage-agent[bot]andMervinPraison a35190b013 fix: consolidate duplicate persistence coordinators (fixes #4383)
Make PraisonAIDB the single owner of the agent-lifecycle store-write
hooks and have PersistenceOrchestrator delegate its message-write and
session-end store dispatch to an internal adapter built via the new
PraisonAIDB._from_stores(), removing the duplicated sync/async store
plumbing while preserving the orchestrator's own return contract and
LRU session cache. Fix the drifted cross-referencing docstrings in both
modules. Public exports (PersistenceOrchestrator, wrap_agent_with_
persistence, PersistentAgent, create_persistent_session) and the
MemoryConfig(db=...) live path are unchanged.

Co-authored-by: MervinPraison <[email protected]>
2026-08-26 18:09:45 +00:00
praisonai-triage-agent[bot]andMervinPraison 92514f5d33 fix: merge duplicate _sanitize_metadata in ChromaDB memory adapters (fixes #4384)
Extract the byte-identical ChromaDB metadata sanitisation logic into a
shared module-level helper `sanitize_chroma_metadata` in
memory/adapters/factories.py. Both Memory._sanitize_metadata and
ChromaMemoryAdapter._sanitize_metadata now delegate to it, preserving
their public method names and behaviour. The distinct core.py JSON-based
variant is left untouched.

Co-authored-by: MervinPraison <[email protected]>
2026-08-26 18:07:14 +00:00
praisonai-triage-agent[bot]andMervinPraison bd3130bb97 fix: document all 11 stream-protocol v2 events and guard against drift (fixes #4391)
The engine emitted eleven stream events but the comment block listed nine,
omitting approval_request (the human-in-the-loop tool gate) and tool_drafting.
Documented payloads had also drifted from what start/end actually carry.

- Update the comment to list all eleven events with their real payloads.
- Add StreamProtocolVocabulary tests that parse the comment and every emit()
  call site, failing on drift in either direction -- so the spec is now a
  checked artifact rather than a comment nothing verifies.

Co-authored-by: MervinPraison <[email protected]>
2026-08-26 18:07:11 +00:00
574 changed files with 87012 additions and 4140 deletions

No files matched your search

-330
View File
@@ -1,330 +0,0 @@
<!-- wp:paragraph -->
<p>praisonai-mcp is Tier 2e — the heavy MCP server host: full <code>MCPServer</code>, tool/resource/prompt registries, recipe bridge, HTTP-stream auth, and MCP client config management. Depends only on <code>praisonaiagents</code>. v0.0.2. Protocol: MCP 2025-11-25.</p>
<!-- /wp:paragraph -->
<!-- wp:merpress/mermaidjs {"align":"full"} -->
<div class="wp-block-merpress-mermaidjs diagram-source-mermaid alignfull"><pre class="mermaid">%%{init: {"theme": "base", "themeVariables": {"background": "transparent", "lineColor": "#000000"}}}%%
flowchart TB
CLIENT[Cursor Claude Desktop]
subgraph T1 [praisonaiagents]
SDK[MCP client ToolsMCPServer]
SEC[mcp_security shared]
end
subgraph T2e [praisonai-mcp]
HOST[MCPServer]
REG[tool resource prompt registry]
TR[stdio http-stream]
AUTH[api-key oauth scopes]
CFG[mcp list add sync]
end
subgraph T2a [praisonai-code optional]
LIGHT[serve mcp light]
BRIDGE[_mcp_bridge]
end
subgraph T3 [praisonai wrapper optional]
CAP[capabilities recipe]
end
CLIENT --> TR
TR --> HOST
HOST --> REG
REG -.->|adapters| CAP
CFG -.->|_code_bridge| LIGHT
BRIDGE --> CFG
SDK --> LIGHT
SEC --> TR
classDef agent fill:#8B0000,color:#fff
classDef tool fill:#189AB4,color:#fff
class CLIENT agent
class SDK agent
class HOST agent
class REG tool
class TR tool
class AUTH tool
class CFG tool
class LIGHT tool
class SEC tool
</pre></div>
<!-- /wp:merpress/mermaidjs -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Three MCP layers (do not conflate)</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Layer</th><th>Package</th><th>Role</th><th>Entry</th></tr></thead><tbody><tr><td>Client</td><td><code>praisonaiagents/mcp/</code></td><td>Connect agents to external MCP servers; OAuth storage</td><td><code>pip install "praisonaiagents[mcp]"</code></td></tr><tr><td>Light server</td><td><code>praisonai-code</code></td><td>Config-driven basic tool hosting</td><td><code>praisonai serve mcp</code></td></tr><tr><td>Heavy host</td><td><code>praisonai-mcp</code></td><td>Full server, adapters, recipe bridge, HTTP auth</td><td><code>praisonai mcp serve</code></td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Install matrix</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Install</th><th><code>praisonai mcp</code></th><th><code>praisonai serve mcp</code></th><th><code>praisonai-mcp</code> script</th></tr></thead><tbody><tr><td><code>pip install praisonai-mcp</code></td><td>—</td><td>—</td><td>✅</td></tr><tr><td><code>pip install "praisonai-mcp[all]"</code></td><td>—</td><td>—</td><td>✅ HTTP + auth</td></tr><tr><td><code>pip install praisonai-code</code> only</td><td>hidden</td><td>✅ light</td><td>—</td></tr><tr><td><code>pip install "praisonai[mcp]"</code></td><td>✅</td><td>✅</td><td>✅</td></tr><tr><td><code>pip install praisonai</code></td><td>✅</td><td>✅</td><td>✅</td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:paragraph -->
<p>1. Standalone heavy host</p>
<!-- /wp:paragraph -->
<!-- wp:code -->
<pre class="wp-block-code"><code>pip install "praisonai-mcp[all]"
export OPENAI_API_KEY=sk-...
praisonai-mcp --version
praisonai-mcp serve --transport stdio
praisonai-mcp list-tools
praisonai-mcp doctor</code></pre>
<!-- /wp:code -->
<!-- wp:paragraph -->
<p>2. Full stack (same commands via <code>praisonai mcp …</code>)</p>
<!-- /wp:paragraph -->
<!-- wp:code -->
<pre class="wp-block-code"><code>pip install praisonai
# or MCP-only extra
pip install "praisonai[mcp]"
praisonai mcp serve --transport stdio
praisonai mcp list-tools
praisonai mcp doctor</code></pre>
<!-- /wp:code -->
<!-- wp:paragraph -->
<p>3. Light server only (no praisonai-mcp required)</p>
<!-- /wp:paragraph -->
<!-- wp:code -->
<pre class="wp-block-code"><code>pip install praisonai-code "praisonaiagents[mcp]"
praisonai serve mcp --port 8080
# Uses agents ToolsMCPServer — not the heavy MCPServer</code></pre>
<!-- /wp:code -->
<!-- wp:separator -->
<hr class="wp-block-separator has-alpha-channel-opacity"/>
<!-- /wp:separator -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 1 — STDIO host (Cursor / Claude Desktop)</h2>
<!-- /wp:heading -->
<!-- wp:code -->
<pre class="wp-block-code"><code># Heavy host — full PraisonAI tool registry
praisonai-mcp serve --transport stdio
# or
praisonai mcp serve --transport stdio
# Generate client config
praisonai mcp config-generate --client cursor --transport stdio
praisonai mcp config-generate --client claude-desktop --transport stdio</code></pre>
<!-- /wp:code -->
<!-- wp:paragraph -->
<p>STDIO uses JSON-RPC over stdin/stdout. Logs go to stderr only — never corrupt the protocol stream.</p>
<!-- /wp:paragraph -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 2 — HTTP Stream host</h2>
<!-- /wp:heading -->
<!-- wp:paragraph -->
<p>Streamable HTTP (MCP 2025-11-25) — not legacy standalone SSE. Requires <code>[server]</code> extra (starlette + uvicorn).</p>
<!-- /wp:paragraph -->
<!-- wp:code -->
<pre class="wp-block-code"><code>praisonai mcp serve \
--transport http-stream \
--host 127.0.0.1 \
--port 8080 \
--endpoint /mcp \
--api-key my-secret-key \
--response-mode batch
# Endpoints: POST/GET/DELETE /mcp, plus /health</code></pre>
<!-- /wp:code -->
<!-- wp:paragraph -->
<p>Non-localhost binds require <code>--api-key</code> or <code>--keys-file</code>. Origin validation uses shared helpers from <code>praisonaiagents.mcp.mcp_security</code>.</p>
<!-- /wp:paragraph -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 3 — Recipe → MCP bridge</h2>
<!-- /wp:heading -->
<!-- wp:paragraph -->
<p>Turn any PraisonAI recipe into a scoped MCP server with allow/deny lists and safe mode.</p>
<!-- /wp:paragraph -->
<!-- wp:code -->
<pre class="wp-block-code"><code>praisonai mcp serve-recipe support-reply --transport stdio
praisonai mcp validate-recipe support-reply
praisonai mcp inspect-recipe support-reply
praisonai mcp config-generate-recipe support-reply --client claude-desktop</code></pre>
<!-- /wp:code -->
<!-- wp:paragraph -->
<p>Default denied tools include <code>shell.exec</code>, <code>file.delete</code>, <code>eval</code>, and similar destructive operations.</p>
<!-- /wp:paragraph -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 4 — MCP client config management</h2>
<!-- /wp:heading -->
<!-- wp:paragraph -->
<p>Config subcommands (<code>list</code>, <code>add</code>, <code>remove</code>, <code>sync</code>, <code>test</code>) require <code>praisonai-code</code> via <code>_code_bridge</code>.</p>
<!-- /wp:paragraph -->
<!-- wp:code -->
<pre class="wp-block-code"><code>praisonai mcp list
praisonai mcp add filesystem \
--command npx \
--args "-y @modelcontextprotocol/server-filesystem ."
praisonai mcp test filesystem
praisonai mcp sync
praisonai mcp tools
praisonai mcp status</code></pre>
<!-- /wp:code -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 5 — Built-in registry (tools, resources, prompts)</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Type</th><th>Examples</th><th>Adapter</th></tr></thead><tbody><tr><td>Tools</td><td><code>praisonai.chat.*</code>, <code>praisonai.workflow.*</code>, agent run helpers</td><td><code>mcp_server/adapters/capabilities.py</code>, <code>cli_tools.py</code></td></tr><tr><td>Resources</td><td><code>praisonai://memory/sessions</code>, <code>praisonai://workflows</code>, <code>praisonai://agents</code></td><td><code>adapters/resources.py</code></td></tr><tr><td>Prompts</td><td><code>deep-research</code>, <code>code-review</code>, <code>workflow-auto</code></td><td><code>adapters/prompts.py</code></td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:code -->
<pre class="wp-block-code"><code># Inspect what the host exposes
praisonai-mcp list-tools
praisonai-mcp list-resources
praisonai-mcp list-prompts
# Host tool search (MCPServerCLI)
praisonai-mcp tools search "workflow"</code></pre>
<!-- /wp:code -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 6 — Security &amp; auth</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Concern</th><th>Package</th><th>Module</th></tr></thead><tbody><tr><td>Origin validation, session IDs</td><td><code>praisonaiagents</code></td><td><code>mcp/mcp_security.py</code></td></tr><tr><td>API keys, scoped keys file</td><td><code>praisonai-mcp</code></td><td><code>mcp_server/auth/api_key.py</code></td></tr><tr><td>OAuth 2.1 + PKCE</td><td><code>praisonai-mcp</code></td><td><code>mcp_server/auth/oauth.py</code></td></tr><tr><td>OIDC discovery</td><td><code>praisonai-mcp</code></td><td><code>mcp_server/auth/oidc.py</code></td></tr><tr><td>Scope enforcement</td><td><code>praisonai-mcp</code></td><td><code>mcp_server/auth/scopes.py</code></td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:code -->
<pre class="wp-block-code"><code># Scoped keys file (--keys-file)
[
{"key": "monitor-key", "name": "monitor", "scopes": ["tools:read", "resources:read"]},
{"key": "admin-key", "name": "admin", "scopes": ["*"]}
]
# Env vars
export MCP_API_KEY=your-key
export PRAISONAI_MCP_MAX_SESSIONS=1000</code></pre>
<!-- /wp:code -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 7 — CLI routing (lazy load)</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Registry</th><th>Commands</th><th>Gate</th></tr></thead><tbody><tr><td>MCP-resident Typer</td><td>list, add, remove, sync, test, serve, doctor</td><td><code>mcp_package_available()</code></td></tr><tr><td>MCPServerCLI host</td><td>serve, list-tools, list-resources, config-generate, tasks, auth</td><td>Direct via <code>praisonai-mcp &lt;cmd&gt;</code></td></tr><tr><td>Code-resident light</td><td><code>serve mcp</code></td><td>Always available with code + agents</td></tr><tr><td>Wrapper-resident</td><td>dashboard, schedule, langfuse</td><td><code>wrapper_package_available()</code></td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:paragraph -->
<p>Standalone <code>praisonai-code</code> hides <code>mcp</code> from <code>--help</code> when <code>praisonai-mcp</code> is not installed. Host commands like <code>praisonai-mcp serve</code> skip the <code>mcp</code> prefix; <code>praisonai mcp serve</code> goes through the Typer group.</p>
<!-- /wp:paragraph -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 8 — Python API</h2>
<!-- /wp:heading -->
<!-- wp:code -->
<pre class="wp-block-code"><code>import praisonai_mcp
from praisonai_mcp.mcp_server import MCPServer
from praisonai_mcp.mcp_server.registry import register_tool
from praisonai_mcp.mcp_server.adapters import register_all
@register_tool("custom.greet")
def greet(name: str) -> str:
"""Greet a person by name."""
return f"Hello, {name}!"
register_all()
server = MCPServer(name="praisonai")
server.run(transport="stdio")</code></pre>
<!-- /wp:code -->
<!-- wp:code -->
<pre class="wp-block-code"><code># Recipe host
from praisonai_mcp.mcp_server import RecipeMCPAdapter
adapter = RecipeMCPAdapter("support-reply")
adapter.load()
adapter.to_mcp_server().run(transport="stdio")
# Shim path still works
from praisonai.mcp_server import MCPServer</code></pre>
<!-- /wp:code -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 9 — Standalone (no wrapper)</h2>
<!-- /wp:heading -->
<!-- wp:code -->
<pre class="wp-block-code"><code>pip install praisonaiagents "praisonai-mcp[all]"
export OPENAI_API_KEY=sk-...
praisonai-mcp serve --transport stdio
praisonai-mcp list-tools
praisonai-mcp doctor
praisonai-mcp --help</code></pre>
<!-- /wp:code -->
<!-- wp:paragraph -->
<p>Heavy MCP hosting needs only <code>praisonaiagents</code> + <code>praisonai-mcp</code>. Full capability registry (workflows, deploy, extended tools) activates when the wrapper is co-installed via <code>_wrapper_bridge</code>.</p>
<!-- /wp:paragraph -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Bridges (no PyPI cycles)</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Bridge</th><th>Direction</th><th>Use</th></tr></thead><tbody><tr><td><code>_mcp_bridge</code></td><td>code → mcp</td><td>Route <code>praisonai mcp</code> into Typer group</td></tr><tr><td><code>_code_bridge</code></td><td>mcp → code</td><td>Config loader, paths, output controller</td></tr><tr><td><code>_wrapper_bridge</code></td><td>mcp → wrapper</td><td>Capabilities, recipe, deploy adapters</td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Backward-compat shims</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Old path</th><th>Still works via</th></tr></thead><tbody><tr><td><code>praisonai.mcp_server.*</code></td><td><code>alias_package</code> → <code>praisonai_mcp.mcp_server</code></td></tr><tr><td><code>praisonai.cli.commands.mcp</code></td><td><code>sys.modules</code> shim → mcp Typer group</td></tr><tr><td><code>from praisonai.mcp_server import MCPServer</code></td><td>Lazy re-export through shim</td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Seven-package publish order</h2>
<!-- /wp:heading -->
<!-- wp:code -->
<pre class="wp-block-code"><code>praisonaiagents → praisonai-code → praisonai-bot → praisonai-train → praisonai-browser → praisonai-mcp → praisonai (wrapper)</code></pre>
<!-- /wp:code -->
<!-- wp:paragraph -->
<p>Wrapper pins <code>praisonai-mcp&gt;=X</code> after mcp is on PyPI. CI: <code>.github/workflows/pypi-release.yml</code>. Import gate: <code>scripts/check_c12_mcp_imports.sh</code>.</p>
<!-- /wp:paragraph -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Summary</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Item</th><th>Value</th></tr></thead><tbody><tr><td>Tier</td><td>2e</td></tr><tr><td>PyPI</td><td><code>praisonai-mcp</code> v0.0.2</td></tr><tr><td>Runtime deps</td><td><code>praisonaiagents</code>, <code>mcp</code>, rich, typer</td></tr><tr><td>HTTP extra</td><td><code>[server]</code> — starlette, uvicorn</td></tr><tr><td>Auth extra</td><td><code>[auth]</code> — httpx</td></tr><tr><td>Transports</td><td><code>stdio</code>, <code>http-stream</code></td></tr><tr><td>Light alternative</td><td><code>praisonai serve mcp</code> (code tier)</td></tr><tr><td>CLI entry</td><td><code>praisonai-mcp</code> or <code>praisonai mcp</code></td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:paragraph -->
<p>Previous: <a href="https://mer.vin/2026/07/praisonai-train-praisonai-train-fine-tuning-agent-training-in-depth/">praisonai-train</a> · Next: praisonai wrapper</p>
<!-- /wp:paragraph -->
-460
View File
@@ -1,460 +0,0 @@
<!-- wp:paragraph -->
<p>praisonai is Tier 3 — the integration wrapper that wires <code>praisonaiagents</code> + five Tier-2 packages (<code>praisonai-code</code>, <code>praisonai-bot</code>, <code>praisonai-train</code>, <code>praisonai-browser</code>, <code>praisonai-mcp</code>) into one install, with dashboard, framework adapters, jobs API, serve orchestration, and backward-compat shims. v4.6.150.</p>
<!-- /wp:paragraph -->
<!-- wp:merpress/mermaidjs {"align":"full"} -->
<div class="wp-block-merpress-mermaidjs diagram-source-mermaid alignfull"><pre class="mermaid">%%{init: {"theme": "base", "themeVariables": {"background": "transparent", "lineColor": "#000000"}}}%%
flowchart TB
subgraph T1 [Tier 1 praisonaiagents]
A[Agent tools memory MCP client]
end
subgraph T2 [Tier 2 packages]
C[praisonai-code run chat code]
B[praisonai-bot gateway channels]
TR[praisonai-train fine-tune agents]
BR[praisonai-browser CDP Playwright]
MCP[praisonai-mcp server host]
end
subgraph T3 [Tier 3 praisonai wrapper]
W[dashboard jobs frameworks serve shims]
end
A --> C
A --> B
A --> TR
A --> BR
A --> MCP
W --> C
W --> B
W --> TR
W --> BR
W --> MCP
W --> A
classDef agent fill:#8B0000,color:#fff
classDef tool fill:#189AB4,color:#fff
class A agent
class W agent
class C tool
class B tool
class TR tool
class BR tool
class MCP tool
</pre></div>
<!-- /wp:merpress/mermaidjs -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Seven-package stack</h2>
<!-- /wp:heading -->
<!-- wp:code -->
<pre class="wp-block-code"><code>praisonaiagents → praisonai-code + praisonai-bot + praisonai-train + praisonai-browser + praisonai-mcp → praisonai (wrapper)</code></pre>
<!-- /wp:code -->
<!-- wp:paragraph -->
<p>Each Tier-2 package is installable standalone with its own console script. The wrapper pins all of them and preserves every old import path via shims. Use the wrapper when you need several surfaces together — HTTP serve, dashboard, framework YAML, jobs/recipe — without managing tier pins yourself.</p>
<!-- /wp:paragraph -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Product catalog (C0–C12 shipped)</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Package</th><th>Epic</th><th>Choose this when…</th><th>Standalone install</th></tr></thead><tbody><tr><td><code>praisonaiagents</code></td><td>Tier 1</td><td>Building or embedding agents in code — Agent, tools, memory, hooks</td><td><code>pip install praisonaiagents</code></td></tr><tr><td><code>praisonai-code</code></td><td>C0–C8</td><td>Terminal agentic CLI — run, chat, code, warm daemon</td><td><code>pip install praisonai-code</code></td></tr><tr><td><code>praisonai-bot</code></td><td>C9</td><td>Messaging bots + WebSocket gateway</td><td><code>pip install "praisonai-bot[gateway,bot]"</code></td></tr><tr><td><code>praisonai-train</code></td><td>C10</td><td>Agent training loops + GPU LLM fine-tuning</td><td><code>pip install praisonai-train</code> or <code>[llm]</code></td></tr><tr><td><code>praisonai-browser</code></td><td>C11</td><td>Chrome extension bridge, CDP, Playwright agents</td><td><code>pip install "praisonai-browser[all]"</code></td></tr><tr><td><code>praisonai-mcp</code></td><td>C12</td><td>MCP server for Cursor, Claude Desktop, Windsurf</td><td><code>pip install "praisonai-mcp[all]"</code></td></tr><tr><td><code>praisonai</code></td><td>Tier 3</td><td>Full product / umbrella — serve, dashboard, frameworks, jobs</td><td><code>pip install praisonai</code></td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Three-layer products (do not conflate)</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Product</th><th>Layer 1 (agents)</th><th>Layer 2 (dedicated pkg)</th><th>Layer 3 (wrapper)</th></tr></thead><tbody><tr><td>Browser</td><td><code>tools/protocols/browser.py</code></td><td><code>praisonai-browser</code></td><td>shims + optional full stack</td></tr><tr><td>MCP</td><td><code>praisonaiagents/mcp</code> client + light server</td><td><code>praisonai-mcp</code> heavy host</td><td>capability bodies via <code>_wrapper_bridge</code></td></tr><tr><td>Bots</td><td>bots/gateway protocols</td><td><code>praisonai-bot</code></td><td>shims only</td></tr><tr><td>Train</td><td>Agent + hooks in agents</td><td><code>praisonai-train</code></td><td>shims + <code>setup-conda-env</code></td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Install matrix</h2>
<!-- /wp:heading -->
<!-- wp:paragraph -->
<p>1. Base install (full seven-package stack)</p>
<!-- /wp:paragraph -->
<!-- wp:code -->
<pre class="wp-block-code"><code>pip install praisonai
export OPENAI_API_KEY=sk-...
praisonai doctor
praisonai version show</code></pre>
<!-- /wp:code -->
<!-- wp:paragraph -->
<p>2. Targeted extras</p>
<!-- /wp:paragraph -->
<!-- wp:code -->
<pre class="wp-block-code"><code>pip install "praisonai[claw]" # dashboard + bots + gateway
pip install "praisonai[all]" # onboarding + messaging + search stack
pip install "praisonai[train]" # LLM fine-tuning (Unsloth stack)
pip install "praisonai[mcp]" # full MCP server host
pip install "praisonai[browser]" # extension bridge + CDP + Playwright
pip install "praisonai[crewai]" # multi-framework YAML</code></pre>
<!-- /wp:code -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Package</th><th>Min version (wrapper pin)</th><th>Console script</th></tr></thead><tbody><tr><td><code>praisonaiagents</code></td><td>≥ 1.6.153</td><td>—</td></tr><tr><td><code>praisonai-code</code></td><td>≥ 0.0.49</td><td><code>praisonai-code</code></td></tr><tr><td><code>praisonai-bot</code></td><td>≥ 0.0.34</td><td><code>praisonai-bot</code></td></tr><tr><td><code>praisonai-train</code></td><td>≥ 0.0.5</td><td><code>praisonai-train</code></td></tr><tr><td><code>praisonai-browser</code></td><td>≥ 0.0.2</td><td><code>praisonai-browser</code></td></tr><tr><td><code>praisonai-mcp</code></td><td>≥ 0.0.2</td><td><code>praisonai-mcp</code></td></tr><tr><td><code>praisonai</code></td><td>4.6.150</td><td><code>praisonai</code></td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:separator -->
<hr class="wp-block-separator has-alpha-channel-opacity"/>
<!-- /wp:separator -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 1 — What wrapper adds (not in Tier-2 alone)</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Feature</th><th>Command / path</th><th>Owner</th><th>Extracted?</th></tr></thead><tbody><tr><td>Unified CLI router</td><td><code>praisonai …</code></td><td>code Typer + 6 resident registries</td><td>—</td></tr><tr><td>HTTP serve orchestration</td><td><code>praisonai serve agents|recipe|a2a|…</code></td><td>wrapper <code>cli/features/serve.py</code></td><td>Deferred (C15 epic)</td></tr><tr><td>Dashboard launcher</td><td><code>praisonai dashboard</code></td><td>wrapper</td><td>Stay wrapper</td></tr><tr><td>Framework adapters</td><td><code>praisonai --framework crewai</code></td><td>wrapper <code>framework_adapters/</code></td><td>Stay wrapper (impls in external PraisonAI-Frameworks)</td></tr><tr><td>Jobs API</td><td><code>praisonai.jobs</code> · port 8005</td><td>wrapper</td><td>Deferred (with serve/recipe)</td></tr><tr><td>Recipe NL → YAML</td><td><code>praisonai recipe …</code></td><td>wrapper</td><td>Deferred</td></tr><tr><td>Scheduler CLI</td><td><code>praisonai schedule …</code></td><td>wrapper</td><td>Stay wrapper (3-layer split with bot)</td></tr><tr><td>Sandbox execution</td><td><code>praisonai sandbox …</code></td><td>wrapper <code>sandbox/</code></td><td>C13 candidate</td></tr><tr><td>Cloud deploy</td><td><code>praisonai deploy …</code></td><td>wrapper <code>deploy/</code></td><td>C13 candidate</td></tr><tr><td>Langfuse / Langflow / n8n</td><td><code>langfuse</code>, <code>flow</code>, <code>n8n</code></td><td>wrapper</td><td>Stay wrapper</td></tr><tr><td>Backward-compat shims</td><td><code>praisonai.bots</code> → <code>praisonai_bot</code></td><td>wrapper <code>cli/_shim.py</code></td><td>Permanent</td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:paragraph -->
<p>Tier-2 packages own their domains; the wrapper owns integration surfaces that span multiple tiers.</p>
<!-- /wp:paragraph -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 2 — Terminal hot path (via code tier)</h2>
<!-- /wp:heading -->
<!-- wp:code -->
<pre class="wp-block-code"><code>pip install praisonai
export OPENAI_API_KEY=sk-...
praisonai run "Summarise the latest AI agent trends"
praisonai chat
praisonai code
praisonai daemon start --background
praisonai run "Hello" # auto-forwards to warm runtime</code></pre>
<!-- /wp:code -->
<!-- wp:code -->
<pre class="wp-block-code"><code># Python SDK (Tier 1 — same everywhere)
from praisonaiagents import Agent
agent = Agent(instructions="You are a helpful AI assistant")
agent.start("Write a movie script about a robot on Mars")</code></pre>
<!-- /wp:code -->
<!-- wp:paragraph -->
<p>Deep dive: <a href="https://mer.vin/2026/07/praisonai-code-praisonai-code-terminal-cli-in-depth/">praisonai-code in-depth</a></p>
<!-- /wp:paragraph -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 3 — Train (via praisonai-train tier)</h2>
<!-- /wp:heading -->
<!-- wp:code -->
<pre class="wp-block-code"><code># LLM fine-tuning (Unsloth) — lazy ML deps
praisonai train llm dataset.json
praisonai train llm --model llama-3.1 dataset.json
# Agent training — LLM-as-judge
praisonai train agents --input "What is Python?" --iterations 3
# Human feedback loop
praisonai train agents --input "Explain quantum computing" --human
# Session management
praisonai train list
praisonai train apply train-abc123 --run "And Germany?"
# Standalone script (no wrapper prefix)
praisonai-train agents --input "Hello" --iterations 2</code></pre>
<!-- /wp:code -->
<!-- wp:paragraph -->
<p>Deep dive: <a href="https://mer.vin/2026/07/praisonai-train-praisonai-train-fine-tuning-agent-training-in-depth/">praisonai-train in-depth</a></p>
<!-- /wp:paragraph -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 4 — Bots &amp; gateway (via bot tier)</h2>
<!-- /wp:heading -->
<!-- wp:code -->
<pre class="wp-block-code"><code>pip install "praisonai[bot]"
export OPENAI_API_KEY=sk-...
export TELEGRAM_BOT_TOKEN=...
praisonai onboard
praisonai bot start --platform telegram
praisonai gateway start --config ~/.praisonai/bot.yaml
praisonai gateway install --config ~/.praisonai/bot.yaml
praisonai pairing approve telegram ABC12345</code></pre>
<!-- /wp:code -->
<!-- wp:paragraph -->
<p>Deep dive: <a href="https://mer.vin/2026/07/praisonai-bot-praisonai-bot-gateway-channels-in-depth/">praisonai-bot in-depth</a></p>
<!-- /wp:paragraph -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 5 — MCP server host (via mcp tier)</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Command</th><th>Layer</th><th>Package</th></tr></thead><tbody><tr><td><code>praisonai serve mcp</code></td><td>Light server</td><td><code>praisonai-code</code> + agents</td></tr><tr><td><code>praisonai mcp serve</code></td><td>Heavy host</td><td><code>praisonai-mcp</code></td></tr><tr><td><code>praisonai-mcp serve</code></td><td>Heavy host (direct)</td><td><code>praisonai-mcp</code></td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:code -->
<pre class="wp-block-code"><code># Heavy MCP host — full tool/resource/prompt registry
praisonai mcp serve --transport stdio
praisonai mcp serve --transport http-stream --port 8080 --api-key secret
# Recipe → scoped MCP server
praisonai mcp serve-recipe support-reply --transport stdio
# Client config management (needs praisonai-code co-installed)
praisonai mcp list
praisonai mcp add filesystem --command npx --args "-y @modelcontextprotocol/server-filesystem ."
# Generate Cursor / Claude Desktop config
praisonai mcp config-generate --client cursor --transport stdio</code></pre>
<!-- /wp:code -->
<!-- wp:paragraph -->
<p>Deep dive: <a href="https://mer.vin/2026/07/praisonai-mcp-praisonai-mcp-server-host-client-config-in-depth/">praisonai-mcp in-depth</a></p>
<!-- /wp:paragraph -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 6 — Browser automation (via browser tier)</h2>
<!-- /wp:heading -->
<!-- wp:code -->
<pre class="wp-block-code"><code>pip install "praisonai[browser]"
# Extension bridge server + LLM browser agent
praisonai browser start
praisonai browser run "Find the latest Python release notes"
# Diagnostics
praisonai browser doctor
# Lightweight tool shim (code tier — no praisonai-browser required)
praisonai browser-tool snapshot https://example.com
# Standalone script
praisonai-browser start
praisonai-browser run "Click the login button"</code></pre>
<!-- /wp:code -->
<!-- wp:paragraph -->
<p>Three layers: agents protocols → <code>praisonai browser-tool</code> (praisonai-tools) → <code>praisonai browser</code> (extension bridge, CDP, Playwright).</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph -->
<p>Deep dive: <a href="https://mer.vin/2026/07/praisonai-browser-praisonai-browser-browser-automation-in-depth/">praisonai-browser in-depth</a></p>
<!-- /wp:paragraph -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 7 — Dashboard stack</h2>
<!-- /wp:heading -->
<!-- wp:code -->
<pre class="wp-block-code"><code>pip install "praisonai[claw,flow,ui]"
# Individual services
praisonai claw # :8082 — ops dashboard
praisonai flow # :7861 — Langflow
praisonai ui # :8081 — clean chat
# Unified launcher (wrapper-only)
praisonai dashboard # :3000
praisonai dashboard --port 9000 --no-auto-start</code></pre>
<!-- /wp:code -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Port</th><th>Service</th></tr></thead><tbody><tr><td>3000</td><td>Dashboard hub</td></tr><tr><td>7860/7861</td><td>Langflow</td></tr><tr><td>8081</td><td>UI chat</td></tr><tr><td>8082</td><td>Claw</td></tr><tr><td>8765</td><td>Gateway WebSocket</td></tr><tr><td>8005</td><td>Jobs API</td></tr><tr><td>8080</td><td>MCP HTTP stream (optional)</td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 8 — Multi-framework YAML</h2>
<!-- /wp:heading -->
<!-- wp:code -->
<pre class="wp-block-code"><code>pip install "praisonai[crewai]"
export OPENAI_API_KEY=sk-...
praisonai --framework crewai --auto "Create a movie script"
praisonai run --file agents.yaml --framework crewai
pip install "praisonai[autogen]"
praisonai --framework autogen agents.yaml</code></pre>
<!-- /wp:code -->
<!-- wp:paragraph -->
<p>Protocols live in <code>praisonaiagents/frameworks/</code>; adapter implementations in wrapper <code>framework_adapters/</code>. Entry-point group: <code>praisonai.framework_adapters</code>.</p>
<!-- /wp:paragraph -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 9 — Scheduler (24/7 agents)</h2>
<!-- /wp:heading -->
<!-- wp:code -->
<pre class="wp-block-code"><code>praisonai schedule add --agent my_agent.yaml --expr "hourly"
praisonai schedule list
praisonai schedule start</code></pre>
<!-- /wp:code -->
<!-- wp:code -->
<pre class="wp-block-code"><code># Python
from praisonai.scheduler import AgentScheduler
from praisonaiagents import Agent
agent = Agent(name="NewsChecker", instructions="Check latest AI news")
scheduler = AgentScheduler(agent=agent, task="Summarise top 3 AI stories")
scheduler.start("hourly", run_immediately=True)</code></pre>
<!-- /wp:code -->
<!-- wp:paragraph -->
<p>Three scheduler layers: job store (<code>praisonaiagents.scheduler</code>) → CLI (<code>praisonai schedule</code>, wrapper) → gateway tick delivery (<code>praisonai_bot.scheduler.executor</code>).</p>
<!-- /wp:paragraph -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 10 — Jobs API &amp; wrapper-only surfaces</h2>
<!-- /wp:heading -->
<!-- wp:paragraph -->
<p>Jobs, recipe, serve, sandbox, and deploy remain wrapper-owned today (deferred or C13 candidates).</p>
<!-- /wp:paragraph -->
<!-- wp:code -->
<pre class="wp-block-code"><code># Jobs API (wrapper — port 8005)
python -m uvicorn praisonai.jobs.server:create_app --port 8005 --factory
praisonai run submit "Analyse AI trends"
praisonai run submit "Analyse news" --recipe news-analyzer --wait
export PRAISONAI_JOBS_API_KEY=your-secret
# HTTP serve (wrapper orchestration — Typer shell in code tier)
praisonai serve agents --port 8080
praisonai serve recipe support-reply
# Sandbox / deploy (wrapper today — C13 extraction candidates)
praisonai sandbox run "print hello" --backend docker
praisonai deploy agents.yaml --provider aws</code></pre>
<!-- /wp:code -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 11 — Lazy CLI routing (six resident registries)</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Registry</th><th>Count</th><th>Examples</th><th>Loaded from</th><th>Gate</th></tr></thead><tbody><tr><td>Code-resident</td><td>~40</td><td>run, chat, code, daemon, serve, config</td><td><code>praisonai_code.cli.commands.*</code></td><td>always</td></tr><tr><td>Bot-resident</td><td>8</td><td>bot, gateway, onboard, kanban, claw</td><td><code>praisonai_bot.cli.commands.*</code></td><td><code>bot_package_available()</code></td></tr><tr><td>Train-resident</td><td>1</td><td>train</td><td><code>praisonai_train.cli.commands.*</code></td><td><code>train_package_available()</code></td></tr><tr><td>Browser-resident</td><td>1</td><td>browser</td><td><code>praisonai_browser.cli.commands.*</code></td><td><code>browser_package_available()</code></td></tr><tr><td>MCP-resident</td><td>1</td><td>mcp</td><td><code>praisonai_mcp.cli.commands.*</code></td><td><code>mcp_package_available()</code></td></tr><tr><td>Wrapper-resident</td><td>22</td><td>dashboard, schedule, flow, langfuse, recipe</td><td><code>praisonai.cli.commands.*</code></td><td><code>wrapper_available()</code></td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:code -->
<pre class="wp-block-code"><code>praisonai --help # 70+ commands, lazy-loaded
praisonai doctor
praisonai train --help
praisonai mcp --help
praisonai browser --help
praisonai dashboard --help</code></pre>
<!-- /wp:code -->
<!-- wp:paragraph -->
<p>Standalone <code>praisonai-code</code> hides bot/train/browser/mcp/wrapper commands from <code>--help</code> when those packages are absent.</p>
<!-- /wp:paragraph -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 12 — Shim map (backward compat)</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Old import / path</th><th>Canonical target</th></tr></thead><tbody><tr><td><code>praisonai.cli.main</code></td><td><code>praisonai_code.cli.main</code></td></tr><tr><td><code>praisonai.runtime.*</code></td><td><code>praisonai_code.runtime.*</code></td></tr><tr><td><code>praisonai.bots.*</code></td><td><code>praisonai_bot.bots.*</code></td></tr><tr><td><code>praisonai.gateway.*</code></td><td><code>praisonai_bot.gateway.*</code></td></tr><tr><td><code>praisonai.train.*</code></td><td><code>praisonai_train.train.*</code></td></tr><tr><td><code>praisonai.browser.*</code></td><td><code>praisonai_browser.*</code></td></tr><tr><td><code>praisonai.mcp_server.*</code></td><td><code>praisonai_mcp.mcp_server.*</code></td></tr><tr><td><code>praisonai.scheduler.executor</code></td><td><code>praisonai_bot.scheduler.executor</code></td></tr><tr><td><code>setup-conda-env</code></td><td><code>praisonai_train.setup.setup_conda_env</code></td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:code -->
<pre class="wp-block-code"><code># These must keep working
from praisonai.cli.main import PraisonAI
from praisonai.bots import Bot
from praisonai.gateway import WebSocketGateway
from praisonai.train.agents import AgentTrainer
from praisonai.mcp_server import MCPServer
python -m praisonai run "hello"
python -m praisonai.runtime</code></pre>
<!-- /wp:code -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Section 13 — Bridges (no PyPI cycles)</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Bridge</th><th>Direction</th><th>Use</th></tr></thead><tbody><tr><td><code>_wrapper_bridge</code></td><td>code → wrapper</td><td>serve handlers, frameworks, jobs</td></tr><tr><td><code>_bot_bridge</code></td><td>code → bot</td><td>bot/gateway CLI routing</td></tr><tr><td><code>_train_bridge</code></td><td>code → train</td><td>train CLI routing</td></tr><tr><td><code>_browser_bridge</code></td><td>code → browser</td><td>browser CLI routing</td></tr><tr><td><code>_mcp_bridge</code></td><td>code → mcp</td><td>mcp CLI routing</td></tr><tr><td><code>_code_bridge</code></td><td>tier-2 → code</td><td>config kernel, legacy dispatch</td></tr><tr><td><code>_wrapper_bridge</code></td><td>tier-2 → wrapper</td><td>capabilities, recipe, jobs/UI</td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:heading -->
<h2 class="wp-block-heading">When to use wrapper vs a Tier-2 package alone</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Your goal</th><th>Install</th></tr></thead><tbody><tr><td>Only terminal agents</td><td><code>praisonai-code</code> (+ optional <code>praisonaiagents</code>)</td></tr><tr><td>Only bots on Telegram/Slack</td><td><code>praisonai-bot</code></td></tr><tr><td>Only train / fine-tune</td><td><code>praisonai-train</code></td></tr><tr><td>Only automate browser</td><td><code>praisonai-browser</code></td></tr><tr><td>Only MCP server for IDE</td><td><code>praisonai-mcp</code></td></tr><tr><td>Several of the above + serve / dashboard / framework YAML</td><td><code>pip install praisonai</code></td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:heading -->
<h2 class="wp-block-heading">What stays in wrapper (not extracted)</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Surface</th><th>Why it stays</th></tr></thead><tbody><tr><td><code>dashboard</code> / <code>flow</code> / <code>ui</code></td><td>Langflow extra + launcher glue — not a standalone PyPI product</td></tr><tr><td><code>claw</code></td><td>Default app lives in <code>praisonai-bot</code>; wrapper is a thin shim</td></tr><tr><td><code>framework_adapters/</code></td><td>Thin registry; heavy impls in external PraisonAI-Frameworks</td></tr><tr><td><code>schedule</code> CLI</td><td>Three-layer split: agents store + wrapper CLI + bot executor</td></tr><tr><td><code>serve</code> / <code>recipe</code> / <code>jobs</code></td><td>~14k LOC deferred epic — high coupling (C15)</td></tr><tr><td><code>sandbox</code> / <code>deploy</code></td><td>Still in wrapper today; C13 extraction candidates</td></tr><tr><td><code>standardise</code> / suite runner</td><td>Maintainer/docs tooling — internal only</td></tr><tr><td><code>persist</code> / enterprise adapters</td><td><strong>Rejected</strong> — agents already ship File/SQLite defaults; no crisp standalone story</td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Roadmap — C13+ candidates</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Package</th><th>Status</th><th>Choose when…</th><th>Standalone install (planned)</th></tr></thead><tbody><tr><td><code>praisonai-sandbox</code></td><td><strong>C13 preferred</strong> (execution goal)</td><td>Isolated agent code execution — Docker/E2B/Modal/Daytona/Sandlock</td><td><code>pip install praisonai-sandbox[docker]</code></td></tr><tr><td><code>praisonai-deploy</code></td><td><strong>C13 alt</strong> (deploy goal)</td><td>Ship <code>agents.yaml</code> to Docker/AWS/GCP/Azure</td><td><code>pip install praisonai-deploy</code></td></tr><tr><td><code>praisonai-replay</code></td><td>Conditional C14+</td><td>Deterministic replay from run ledger / traces</td><td>After product sign-off</td></tr><tr><td><code>praisonai-serve</code></td><td>Deferred C15</td><td>HTTP API hosting without full umbrella (~14k LOC)</td><td>With recipe/jobs decision</td></tr><tr><td><code>praisonai-recipe</code></td><td>Deferred</td><td>NL → agent YAML without jobs UI</td><td>Tied to jobs + serve</td></tr><tr><td><code>praisonai-jobs</code></td><td>Deferred</td><td>Async long-running agent jobs API</td><td>Extract with serve or stay wrapper</td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:paragraph -->
<p>C13 decision rule: <strong>sandbox</strong> = where code runs; <strong>deploy</strong> = where the agent service lives. Ship one first after product sign-off.</p>
<!-- /wp:paragraph -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Standalone extraction litmus test</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Criterion</th><th>Pass (browser/MCP)</th><th>Fail (example)</th></tr></thead><tbody><tr><td>Named user goal without full <code>praisonai</code></td><td><code>pip install praisonai-browser</code> → automate Chrome</td><td><code>pip install praisonai-persist</code> → ???</td></tr><tr><td>Coherent product boundary</td><td>One Typer group / one console script</td><td>Whole <code>cli/features/</code> orchestration layer</td></tr><tr><td>PyPI deps: <code>praisonaiagents</code> only</td><td>Tier-2 + lazy <code>_wrapper_bridge</code></td><td>Hard <code>import praisonai</code> at module level</td></tr><tr><td>Worth a separate install</td><td>User who never wants dashboard/serve/bots</td><td>Internal docs tooling</td></tr><tr><td>Extraction shape</td><td>C10 single PR (~3–11k LOC)</td><td>Mega-epic without product sign-off</td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:heading -->
<h2 class="wp-block-heading">When to use which tier</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Scenario</th><th>Install</th></tr></thead><tbody><tr><td>Pure Python agents, minimal deps</td><td><code>pip install praisonaiagents</code></td></tr><tr><td>Terminal coding agent, CI, headless</td><td><code>pip install praisonai-code</code></td></tr><tr><td>Telegram/Discord gateway only</td><td><code>pip install "praisonai-bot[gateway,bot]"</code></td></tr><tr><td>Agent training (no GPU)</td><td><code>pip install praisonai-train</code></td></tr><tr><td>LLM fine-tuning (GPU)</td><td><code>pip install "praisonai-train[llm]"</code></td></tr><tr><td>MCP server for Cursor/Claude</td><td><code>pip install "praisonai-mcp[all]"</code></td></tr><tr><td>Browser automation (extension/CDP)</td><td><code>pip install "praisonai-browser[all]"</code></td></tr><tr><td>CrewAI/AutoGen YAML</td><td><code>pip install "praisonai[crewai]"</code></td></tr><tr><td>Full ops + channels + dashboard</td><td><code>pip install "praisonai[claw]"</code></td></tr><tr><td>Everything in one install</td><td><code>pip install praisonai</code></td></tr><tr><td>Library author / embedding</td><td><code>praisonaiagents</code> only</td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Seven-package publish order</h2>
<!-- /wp:heading -->
<!-- wp:code -->
<pre class="wp-block-code"><code>praisonaiagents
→ praisonai-code + praisonai-bot + praisonai-train + praisonai-browser + praisonai-mcp
→ praisonai (wrapper pins all tier-2 deps last)</code></pre>
<!-- /wp:code -->
<!-- wp:paragraph -->
<p>CI: <code>.github/workflows/pypi-release.yml</code> · selective publish: <code>publish_all.py --changed-only</code> (default). Playbook per package: manifest → TDD compat tests → git mv → shims → <code>_X_RESIDENT_COMMANDS</code> → CI shard → PyPI.</p>
<!-- /wp:paragraph -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Summary</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figure class="wp-block-table"><table><thead><tr><th>Item</th><th>Value</th></tr></thead><tbody><tr><td>Tier</td><td>3 (integration wrapper)</td></tr><tr><td>PyPI</td><td><code>praisonai</code> v4.6.150</td></tr><tr><td>Shipped tier-2 deps</td><td>code, bot, train, browser, mcp (C0–C12)</td></tr><tr><td>Resident registries</td><td>6 (code + bot + train + browser + mcp + wrapper)</td></tr><tr><td>Wrapper-only (~140k LOC)</td><td>serve, recipe, jobs, dashboard, sandbox, deploy, frameworks</td></tr><tr><td>Next extraction</td><td>C13: sandbox or deploy (product sign-off)</td></tr><tr><td>One-liner still works</td><td><code>pip install praisonai</code></td></tr></tbody></table></figure>
<!-- /wp:table -->
<!-- wp:paragraph -->
<p>Architecture overview: Seven-package C7–C12 extraction · Post-C12 roadmap (C13+)</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph -->
<p>Series: <a href="https://mer.vin/2026/07/praisonai-code-praisonai-code-terminal-cli-in-depth/">praisonai-code</a> · <a href="https://mer.vin/2026/07/praisonai-bot-praisonai-bot-gateway-channels-in-depth/">praisonai-bot</a> · <a href="https://mer.vin/2026/07/praisonai-train-praisonai-train-fine-tuning-agent-training-in-depth/">praisonai-train</a> · <a href="https://mer.vin/2026/07/praisonai-browser-praisonai-browser-browser-automation-in-depth/">praisonai-browser</a> · <a href="https://mer.vin/2026/07/praisonai-mcp-praisonai-mcp-server-host-client-config-in-depth/">praisonai-mcp</a> · praisonai wrapper</p>
<!-- /wp:paragraph -->
@@ -1,86 +0,0 @@
---
name: praisonai-js-hub-intake
description: >-
Explains Option B hub intake for PraisonAI TypeScript/JavaScript (npm praisonai):
accept TS issues on MervinPraison/PraisonAI, implement fixes in MervinPraison/praisonai-js,
sync to src/praisonai-ts/. Use when routing issues, implementing TS fixes, sync workflows,
merge-gate TS paths, or when the user mentions praisonai-js, praisonai-ts, npm SDK, or TS hub intake.
---
# praisonai-js hub intake (Option B)
## Model in one sentence
**Users file TypeScript issues on [PraisonAI](https://github.com/MervinPraison/PraisonAI); fixes land in [praisonai-js](https://github.com/MervinPraison/praisonai-js); the monorepo `src/praisonai-ts/` is a sync mirror only.**
Do **not** ask users to re-file on praisonai-js unless they opened a duplicate on the wrong repo.
## Routing table
| Topic | Report | Fix repo | Monorepo mirror |
|-------|--------|----------|-----------------|
| Python SDK / CLI | PraisonAI | `src/praisonai-agents/`, `src/praisonai/` | — |
| TypeScript / npm `praisonai` | PraisonAI (preferred) or praisonai-js | **praisonai-js** | `src/praisonai-ts/` |
| Cross-language parity | PraisonAI | Often both Python + praisonai-js | sync after TS merge |
| Agent-callable tools | PraisonAI | PraisonAI-Tools | — |
| Lifecycle plugins | PraisonAI | PraisonAI-Plugins | — |
## End-to-end flow
```
Issue on PraisonAI
→ issue-triage: labels javascript, praisonai-js (if TS focus)
→ claude label → Claude STEP 3-ALT clones praisonai-js
→ PR on praisonai-js with body: Fixes MervinPraison/PraisonAI#N
→ merge on praisonai-js (CI + merge gate there)
→ Sync to PraisonAI Monorepo workflow on praisonai-js
→ PR on PraisonAI: sync: update src/praisonai-ts/...
```
## Agent rules
1. **Never implement TS features in `src/praisonai-ts/` on PraisonAI** — edit praisonai-js instead.
2. **External PR body** must include `Fixes MervinPraison/PraisonAI#<n>` when fixing a hub issue.
3. **After praisonai-js merge**, run sync workflow (or tell maintainer to):
```bash
gh workflow run "Sync to PraisonAI Monorepo" \
--repo MervinPraison/praisonai-js \
-f upstream_issue=<issue_number>
```
4. **Merge gate on PraisonAI** blocks direct `src/praisonai-ts/` changes unless sync PR (title `sync:` or body mentions praisonai-js).
5. **Tests for TS fixes**: `npm install && npm run build && npm test` in praisonai-js clone.
## TS issue detection (PraisonAI triage)
Labels `javascript` + `praisonai-js` when issue mentions: `praisonai-js`, `praisonai-ts`, `src/praisonai-ts`, `typescript`+`praisonai`, `npm`+`praisonai` (not pypi).
## Key files
See [reference.md](reference.md) for paths in both repos.
## Quick commands
```bash
# Sync Claude secrets to praisonai-js (from PraisonAI hub)
gh workflow run "Sync Claude Secrets" \
--repo MervinPraison/PraisonAI \
-f target_repo=MervinPraison/praisonai-js
# Sync code praisonai-js → monorepo mirror
gh workflow run "Sync to PraisonAI Monorepo" \
--repo MervinPraison/praisonai-js \
-f upstream_issue=1234
```
## Related docs
- Monorepo: `CONTRIBUTING.md` (hub intake table)
- praisonai-js: `AGENTS.md` §2.1.1 Hub intake
- Security: `SECURITY.md` (npm canonical = praisonai-js)
## Cursor skill locations
| Repo | Path |
|------|------|
| PraisonAI monorepo | `.cursor/skills/praisonai-js-hub-intake/` |
| praisonai-js | `.cursor/skills/praisonai-js-hub-intake/` |
@@ -1,72 +0,0 @@
# praisonai-js hub intake — file reference
## PraisonAI monorepo (`MervinPraison/PraisonAI`)
| File | Role |
|------|------|
| `.github/workflows/claude.yml` | Issue triage labels; STEP 3-ALT to praisonai-js; npm test in external flow |
| `.github/scripts/merge-gate.js` | `tsMirrorPathReasons()` blocks non-sync edits to `src/praisonai-ts/` |
| `.github/workflows/claude-merge-gate.yml` | Assess checklist: TS → praisonai-js |
| `.github/scripts/ci-failure-claude.js` | CI fix guardrails include TS routing |
| `.github/actions/claude-code-action/action.yml` | Shared Claude routing (TS → praisonai-js) |
| `.github/praisonai-issue-triage.yaml` | `@praisonai` triage: TS-external routing |
| `.github/workflows/auto-issue-comment.yml` | Auto-triage comment mentions praisonai-js |
| `.github/ISSUE_TEMPLATE/bug_report.md` | Python / TS / cross-language selector |
| `CONTRIBUTING.md` | Hub intake table |
| `SECURITY.md` | npm canonical source = praisonai-js |
| `src/praisonai-ts/` | **Mirror only** — do not implement features here |
| `.cursor/skills/praisonai-js-hub-intake/` | This skill (agent reference) |
### Commits (Option B rollout)
- `4ab1ed280` — Implement Option B hub intake for praisonai-js TypeScript issues
## praisonai-js (`MervinPraison/praisonai-js`)
| File | Role |
|------|------|
| `.github/workflows/sync-to-praisonai.yml` | Rsync to `src/praisonai-ts/`; optional `upstream_issue` input |
| `.github/pull_request_template.md` | `Fixes MervinPraison/PraisonAI#N` + sync checklist |
| `.github/ISSUE_TEMPLATE/bug_report.md` | TS bug template; link to PraisonAI for cross-stack |
| `.github/ISSUE_TEMPLATE/config.yml` | Contact links → PraisonAI for Python/cross-stack |
| `.github/scripts/gate-config.js` | Scope + `externalRepos` hub-intake config |
| `.github/workflows/claude.yml` | Hub caller (Claude automation) |
| `AGENTS.md` §2.1.1 | Hub intake for agents |
| `README.md` | Reporting issues table |
| `.cursor/skills/praisonai-js-hub-intake/` | This skill (agent reference) |
### Commits
- `0c8cc3f` — Complete Option B reciprocal hub intake
- `bfc5e34` — Add praisonai-js-hub-intake Cursor skill
## github-automation-template
| Item | Role |
|------|------|
| `install.sh --mode hub` | Thin callers on consumer repos |
| `stacks/claude/profiles/sdk/gate-config.js.tmpl` | Base gate config (customise per repo) |
| `AGENTS.md` | Install / rollout guide |
praisonai-js uses hub mode `@v1.1.0`.
## Decision tree for agents
```
Is the issue about npm / TypeScript / praisonai-js / src/praisonai-ts?
├─ YES → Fix in MervinPraison/praisonai-js (STEP 3-ALT)
│ PR: Fixes MervinPraison/PraisonAI#N
│ After merge: Sync to PraisonAI Monorepo
└─ NO → Is it Python praisonaiagents / praisonai CLI?
├─ YES → Fix in this monorepo
└─ NO → Tools / Plugins / Docs external repos (see claude.yml STEP 2)
```
## Anti-patterns
| Wrong | Right |
|-------|-------|
| Edit `src/praisonai-ts/` for a new TS feature | Edit praisonai-js, then sync |
| Tell user to re-file on praisonai-js only | Accept on PraisonAI; route fix externally |
| Merge gate approves direct TS mirror PR without sync metadata | Title `sync:` or body references praisonai-js |
| Skip `Fixes MervinPraison/PraisonAI#N` on external PR | Always cross-link hub issues |
+1 -1
View File
@@ -8,7 +8,7 @@ assignees: ''
## SDK / package
- [ ] Python (`pip install praisonai` / `praisonaiagents`)
- [ ] TypeScript / JavaScript (`npm install praisonai`) — fixes land in [praisonai-js](https://github.com/MervinPraison/praisonai-js); you can report here or there
- [ ] TypeScript / JavaScript (`npm install praisonai`) — fixes in `src/praisonai-ts/`
- [ ] Cross-language / parity (file here on PraisonAI)
## Environment
@@ -225,10 +225,10 @@ runs:
- CORE vs WRAPPER vs TOOLS vs PLUGINS ROUTING:
1. Core SDK (praisonaiagents/): Only core protocols, base classes, decorators. No heavy implementations.
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
3. TypeScript/JavaScript SDK → MervinPraison/praisonai-js: canonical npm repo. Do NOT edit src/praisonai-ts/ here.
4. Tools → MervinPraison/PraisonAI-Tools: agent-callable integrations at runtime (Composio, Slack, APIs). Do NOT add to praisonaiagents/.
5. Plugins → MervinPraison/PraisonAI-Plugins: lifecycle extensions (tracing, logging, hooks, guardrails). Not agent tools.
6. Sandbox backends → PraisonAI-Plugins via `praisonai.sandbox` entry point (core protocol only).
3. Tools → MervinPraison/PraisonAI-Tools: agent-callable integrations at runtime (Composio, Slack, APIs). Do NOT add to praisonaiagents/.
4. Plugins → MervinPraison/PraisonAI-Plugins: lifecycle extensions (tracing, logging, hooks, guardrails). Not agent tools.
5. Sandbox backends → PraisonAI-Plugins via `praisonai.sandbox` entry point (core protocol only).
6. TypeScript SDK → src/praisonai-ts/ in this monorepo (read src/praisonai-ts/AGENTS.md for TS rules).
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
- Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
+4 -3
View File
@@ -33,19 +33,20 @@ steps:
Second, read the issue: execute_command 'cat .github/triage-context.md'. Do NOT pass cwd.
Third, read architecture guidelines: execute_command 'cat src/praisonai-agents/AGENTS.md'.
If the issue is TypeScript/JavaScript SDK, also execute_command 'cat src/praisonai-ts/AGENTS.md'.
Fourth, decide routing before planning:
- TypeScript/JavaScript SDK → MervinPraison/praisonai-js: canonical npm repo. Do NOT edit src/praisonai-ts/ in this monorepo. Cross-link upstream issue in external PR body: Fixes MervinPraison/PraisonAI#N.
- TypeScript/JavaScript SDK → src/praisonai-ts/ in this monorepo (read src/praisonai-ts/AGENTS.md).
- Tools → PraisonAI-Tools: agent-callable integrations at runtime (Composio, Slack, APIs).
- Plugins → PraisonAI-Plugins: lifecycle extensions (tracing, logging, hooks, guardrails).
- Sandbox backends → PraisonAI-Plugins via `praisonai.sandbox` entry point.
Core SDK/wrapper only for protocols and CLI — external repos for TS SDK/Tools/Plugins.
Core SDK/wrapper for Python protocols and CLI; external repos for Tools/Plugins/Docs only.
Fifth, decide if this issue requires code changes:
- If YES: identify the exact files and lines that must change. Output a concrete file-level plan.
- If NO (question, discussion, duplicate, invalid): say so clearly. No code changes needed.
Output: routing decision (Core / Wrapper / TS-external / Tools-external / Plugins-external / No-change), validation result, and exact files/lines to change.
Output: routing decision (Core / Wrapper / TS-monorepo / Tools-external / Plugins-external / No-change), validation result, and exact files/lines to change.
expected_output: A decision on whether code changes are needed, and if so a concrete file-level plan.
- name: Implement Fix
+1 -1
View File
@@ -3,7 +3,7 @@
* @see .github/workflows/auto-pr-comment.yml, claude.yml, bot-pr-recovery.yml
*/
const KICK_AUTHORS = new Set(['MervinPraison', 'github-actions[bot]']);
const KICK_AUTHORS = new Set(['MervinPraison', 'github-actions[bot]', 'praisonai-triage-agent[bot]']);
const BOT_PR_AUTHORS = new Set(['praisonai-triage-agent[bot]', 'github-actions[bot]']);
async function listAllComments(github, owner, repo, issueNumber) {
-1
View File
@@ -157,7 +157,6 @@ function buildCiFixComment({ headSha, failedChecks, failureSummaries }) {
'3. **Pre-existing flake or unrelated failure** — say so explicitly; prefer fixing the root cause over masking it.',
'',
'**SDK guardrails (AGENTS.md):**',
'- TypeScript/JavaScript SDK → MervinPraison/praisonai-js (not src/praisonai-ts/ in this monorepo).',
'- Agent-callable tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins; sandbox backends → PraisonAI-Plugins (`praisonai.sandbox`) — not praisonaiagents/.',
'- Do not disturb core SDK contracts to accommodate a wrapper/feature change.',
'- Tests must continue to guard backward compatibility and hot-path behaviour — passing CI by lowering test standards is not acceptable.',
+39
View File
@@ -272,6 +272,16 @@ assert('sdk with tests ok', mg.missingTestsReason([
{ filename: 'src/praisonai-agents/a/b.py', additions: 3 },
{ filename: 'src/praisonai-agents/tests/test_x.py', additions: 10 },
]) === null);
assert('ts sdk without tests', mg.missingTestsReason([
{ filename: 'src/praisonai-ts/src/agent/simple.ts', additions: 5 },
]) !== null);
assert('ts sdk with tests ok', mg.missingTestsReason([
{ filename: 'src/praisonai-ts/src/agent/simple.ts', additions: 5 },
{ filename: 'src/praisonai-ts/tests/unit/agent/history-restore.test.ts', additions: 10 },
]) === null);
assert('touches ts sdk', mg.touchesSdk([{ filename: 'src/praisonai-ts/src/agent/simple.ts' }]));
assert('final body requires sdk value table', mg.FINAL_CLAUDE_REVIEW_BODY.includes('| **SDK value** |'));
assert('final body requires ts agents md', mg.FINAL_CLAUDE_REVIEW_BODY.includes('src/praisonai-ts/AGENTS.md'));
// PR size
assert('large PR blocked', mg.prSizeReasons([{ additions: 900 }]).length > 0);
@@ -287,4 +297,33 @@ assert('fork sync PR link rejected', !mg.isInternalPullRequestLink(
'PraisonAI'
));
assert('dispatch blocked when merge gate active', mg.mergeGateDispatchBlockedReason({
labels: ['claude-merge-gate-active'],
pendingRuns: 0,
}) === 'merge gate already active on PR');
assert('dispatch blocked when queue saturated', mg.mergeGateDispatchBlockedReason({
labels: [],
pendingRuns: 3,
})?.includes('already queued or in progress'));
assert('dispatch allowed when queue below cap', mg.mergeGateDispatchBlockedReason({
labels: [],
pendingRuns: 2,
}) == null);
// Pending count excludes workflow_run auto-dispatch probes (PR #4534)
assert('workflow_run probes excluded from pending count', mg.countSubstantiveMergeGateRuns([
{ event: 'workflow_dispatch' },
{ event: 'workflow_run' },
{ event: 'workflow_run' },
]) === 1);
assert('all-probe runs count as zero pending', mg.countSubstantiveMergeGateRuns([
{ event: 'workflow_run' },
{ event: 'workflow_run' },
]) === 0);
assert('substantive dispatches still counted', mg.countSubstantiveMergeGateRuns([
{ event: 'workflow_dispatch' },
{ event: 'schedule' },
]) === 2);
assert('countSubstantiveMergeGateRuns null-safe', mg.countSubstantiveMergeGateRuns(null) === 0);
process.exit(failed ? 1 : 0);
+97 -27
View File
@@ -17,7 +17,8 @@ const PR_MAX_AUTO_FILES = 30;
const MANUAL_ONLY_LABELS = new Set(['security', 'breaking-change', 'needs-manual-review', 'release']);
const WORKFLOW_ONLY_LABEL = 'merge-gate-ci-only';
const CI_ONLY_PATH_PREFIXES = ['.github/workflows/', '.github/actions/', '.github/scripts/merge-gate'];
const SDK_PATH_PREFIXES = ['src/praisonai-agents/', 'src/praisonai/'];
const SDK_PATH_PREFIXES = ['src/praisonai-agents/', 'src/praisonai/', 'src/praisonai-ts/'];
const TS_SDK_SOURCE_PREFIX = 'src/praisonai-ts/src/';
const SENSITIVE_PATH_PATTERNS = [
/^\.github\/workflows\//,
/praisonaiagents\/(auth|approval|policy|sandbox)\//,
@@ -26,7 +27,17 @@ const SENSITIVE_PATH_PATTERNS = [
/\.env(\.|$)/,
/credentials\.json$/i,
];
const REQUIRED_SDK_CHECK_PATTERNS = [/test/i, /smoke/i, /core/i, /python package/i, /comprehensive/i, /optimized/i];
const REQUIRED_SDK_CHECK_PATTERNS = [
/test/i,
/smoke/i,
/core/i,
/python package/i,
/comprehensive/i,
/optimized/i,
/praisonai-ts/i,
/npm/i,
/webview/i,
];
const SECRET_PATTERNS = [
/sk-[a-zA-Z0-9]{20,}/,
/AKIA[0-9A-Z]{16}/,
@@ -308,7 +319,7 @@ function finalClaudeCompletedOnSha(comments, headPushedAt) {
}
const FINAL_CLAUDE_REVIEW_BODY =
'@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify src/praisonai-rust or src/praisonai-ts in this repo — TypeScript SDK work belongs in MervinPraison/praisonai-js. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n6. Repo routing: TypeScript/JavaScript SDK → praisonai-js (not src/praisonai-ts/ here); agent-callable tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins; optional sandbox backends → PraisonAI-Plugins (`praisonai.sandbox` entry point) — request changes if wrongly added to praisonaiagents/\n\n**Phase 2: FIX Valid Issues**\n7. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n8. Also independently identify and fix any gaps or issues you find in the changed code — do not rely only on prior reviewer feedback\n9. Push all code fixes directly to THIS branch (do NOT create a new PR)\n10. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n11. If all issues are resolved, approve the PR / close the Issue\n12. If blocking issues remain, request changes / leave clear action items';
'@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Review changes in this PR. Python SDK: praisonaiagents, praisonai. TypeScript SDK: src/praisonai-ts/. Do NOT modify src/praisonai-rust. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**MANDATORY READ (before reviewing):**\n- Always read src/praisonai-agents/AGENTS.md\n- If this PR touches src/praisonai-ts/, also read src/praisonai-ts/AGENTS.md §2.1.2 (TS triage + PR review checklist)\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n6. Repo routing: agent-callable tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins; optional sandbox backends → PraisonAI-Plugins (`praisonai.sandbox` entry point) — request changes if wrongly added to praisonaiagents/\n\n**MANDATORY COMMENT FORMAT — include this Phase 1 table in your review comment:**\n#### Phase 1 — AGENTS.md review\n| Check | Result |\n|---|---|\n| Protocol-driven / no heavy impl in core | ✅ or ❌ + one-line rationale |\n| Backward compatible | ✅ or ❌ + one-line rationale |\n| Performance (hot path) | ✅ or ❌ + one-line rationale |\n| **SDK value** | ✅ or ❌ + one-line rationale (explicitly judge whether the change strengthens the SDK) |\n| No Agent param bloat | ✅ or ❌ + one-line rationale |\n| Repo routing | ✅ or ❌ + one-line rationale |\n\nFor TypeScript PRs (src/praisonai-ts/), also add:\n| TS types / parity / tests | ✅ or ❌ + one-line rationale (npm run build && npm test) |\n\n**Phase 2: FIX Valid Issues**\n7. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n8. Also independently identify and fix any gaps or issues you find in the changed code — do not rely only on prior reviewer feedback\n9. Push all code fixes directly to THIS branch (do NOT create a new PR)\n10. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n11. If all issues are resolved, approve the PR / close the Issue\n12. If blocking issues remain, request changes / leave clear action items';
async function getMergeState(github, owner, repo, prNumber) {
const query = `
@@ -464,6 +475,61 @@ function hasBlockingClaudeRunForPr(runs, headRef) {
return (runs || []).some((r) => claudeRunBlocksPr(r, headRef));
}
const MERGE_GATE_WORKFLOW_FILE = 'claude-merge-gate.yml';
const MERGE_GATE_ACTIVE_LABEL = 'claude-merge-gate-active';
const MAX_PENDING_MERGE_GATE_RUNS = 3;
function mergeGateDispatchBlockedReason({ labels, pendingRuns, maxPending = MAX_PENDING_MERGE_GATE_RUNS }) {
if ((labels || []).includes(MERGE_GATE_ACTIVE_LABEL)) {
return 'merge gate already active on PR';
}
if (pendingRuns >= maxPending) {
return `${pendingRuns} merge gate run(s) already queued or in progress (max ${maxPending})`;
}
return null;
}
// workflow_run probes (auto-dispatch) share concurrency and must not count
// toward backpressure — they blocked dispatches for merge-ready PRs.
function countSubstantiveMergeGateRuns(runs) {
return (runs || []).filter((r) => r && r.event !== 'workflow_run').length;
}
async function countPendingMergeGateRuns(github, owner, repo) {
let pending = 0;
for (const status of ['queued', 'in_progress']) {
try {
const { data } = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: MERGE_GATE_WORKFLOW_FILE,
status,
per_page: 30,
});
pending += countSubstantiveMergeGateRuns(data.workflow_runs);
} catch {
// Best-effort backpressure only.
}
}
return pending;
}
async function shouldSkipMergeGateDispatch(github, owner, repo, prNumber, core, options = {}) {
const maxPending = options.maxPending ?? MAX_PENDING_MERGE_GATE_RUNS;
const ctx = await loadPrContext(github, owner, repo, prNumber);
const pendingRuns = await countPendingMergeGateRuns(github, owner, repo);
const reason = mergeGateDispatchBlockedReason({
labels: ctx.labels,
pendingRuns,
maxPending,
});
if (reason) {
core?.info?.(`Skip merge gate dispatch for PR #${prNumber}: ${reason}`);
return true;
}
return false;
}
async function hasInProgressClaudeAssistant(github, owner, repo, prNumber = null) {
try {
const { data } = await github.rest.actions.listWorkflowRuns({
@@ -600,24 +666,6 @@ function hasManualOnlyLabel(labels) {
return labels.some((l) => MANUAL_ONLY_LABELS.has(l));
}
const TS_MIRROR_PREFIX = 'src/praisonai-ts/';
function tsMirrorPathReasons(files, pr) {
const touchesTs = files.some((f) => f.filename.startsWith(TS_MIRROR_PREFIX));
if (!touchesTs) return [];
const title = (pr.title || '').toLowerCase();
const body = (pr.body || '').toLowerCase();
const isSyncPr =
title.startsWith('sync:') ||
body.includes('sync from praisonai-js') ||
body.includes('praisonai-js') ||
body.includes('mervinpraison/praisonai-js');
if (isSyncPr) return [];
return [
'src/praisonai-ts/ changed outside praisonai-js sync — implement in MervinPraison/praisonai-js, then run Sync to PraisonAI Monorepo workflow',
];
}
function sensitivePathReasons(files, labels = []) {
if (labels.includes(WORKFLOW_ONLY_LABEL) && isCiOnlyChange(files)) {
return [];
@@ -645,18 +693,35 @@ function prSizeReasons(files) {
}
function missingTestsReason(files) {
const sdkAdds = files.filter(
const pySdkAdds = files.filter(
(f) =>
f.filename.startsWith('src/praisonai-agents/') &&
f.filename.endsWith('.py') &&
!f.filename.endsWith('__init__.py') &&
(f.additions || 0) > 0
);
if (sdkAdds.length === 0) return null;
const hasTestChange = files.some(
const tsSdkAdds = files.filter(
(f) =>
f.filename.startsWith(TS_SDK_SOURCE_PREFIX) &&
f.filename.endsWith('.ts') &&
!f.filename.endsWith('.d.ts') &&
(f.additions || 0) > 0
);
if (pySdkAdds.length === 0 && tsSdkAdds.length === 0) return null;
const hasPyTestChange = files.some(
(f) => /\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename) || /_test\.py$/.test(f.filename)
);
if (!hasTestChange) return 'SDK code added without test file changes — requires manual review';
const hasTsTestChange = files.some(
(f) =>
f.filename.startsWith('src/praisonai-ts/tests/') ||
/\.(test|spec)\.ts$/.test(f.filename)
);
if (pySdkAdds.length > 0 && !hasPyTestChange) {
return 'Python SDK code added without test file changes — requires manual review';
}
if (tsSdkAdds.length > 0 && !hasTsTestChange) {
return 'TS SDK code added without test file changes — requires manual review';
}
return null;
}
@@ -841,7 +906,6 @@ async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, op
if (agentManual) reasons.push(agentManual);
reasons.push(...sensitivePathReasons(pullFiles, ctx.labels));
reasons.push(...tsMirrorPathReasons(pullFiles, ctx.pr));
reasons.push(...prSizeReasons(pullFiles));
reasons.push(...secretScanReasons(pullFiles));
@@ -1008,7 +1072,6 @@ module.exports = {
touchesSdk,
hasManualOnlyLabel,
sensitivePathReasons,
tsMirrorPathReasons,
prSizeReasons,
missingTestsReason,
secretScanReasons,
@@ -1033,4 +1096,11 @@ module.exports = {
isAutomatedFallbackVerdict,
AUTOMATED_FALLBACK_MARKER,
findMergeGateVerdict,
MERGE_GATE_WORKFLOW_FILE,
MERGE_GATE_ACTIVE_LABEL,
MAX_PENDING_MERGE_GATE_RUNS,
mergeGateDispatchBlockedReason,
countSubstantiveMergeGateRuns,
countPendingMergeGateRuns,
shouldSkipMergeGateDispatch,
};
+2 -2
View File
@@ -181,10 +181,10 @@ async function syncPipelineLabels(github, owner, repo, prNumber, core) {
async function dispatchMergeGateForOldestReady(github, owner, repo, readyCandidates, core) {
if (!readyCandidates.length) return 0;
const mergeGate = require('./merge-gate.js');
readyCandidates.sort((a, b) => a.createdAt - b.createdAt);
for (const cand of readyCandidates) {
if ((cand.labels || []).includes('claude-merge-gate-active')) {
core?.info?.(`Skip dispatch PR #${cand.prNumber}: merge gate already active`);
if (await mergeGate.shouldSkipMergeGateDispatch(github, owner, repo, cand.prNumber, core)) {
continue;
}
await github.rest.repos.createDispatchEvent({
+1 -1
View File
@@ -42,5 +42,5 @@ jobs:
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '🤖 Auto-triaging with Claude. Analyzing issue and implementing a fix...\n\nClaude will:\n1. Route correctly: TypeScript/JavaScript SDK → praisonai-js; agent tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins\n2. Search codebase for root cause\n3. Implement a minimal fix following AGENTS.md\n4. Create a PR with the changes (external repo PRs link back to this issue)'
body: '🤖 Auto-triaging with Claude. Analyzing issue and implementing a fix...\n\nClaude will:\n1. Route correctly: TypeScript → src/praisonai-ts/; agent tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins\n2. Search codebase for root cause\n3. Implement a minimal fix following AGENTS.md\n4. Create a PR with the changes'
});
+8 -3
View File
@@ -33,7 +33,8 @@ on:
- claude-merge-gate
concurrency:
group: claude-merge-gate-${{ github.repository }}-${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || github.event.workflow_run.id || 'scan' }}
# Serialize workflow_run auto-dispatches (Core Tests / Claude Assistant fan-out).
group: claude-merge-gate-${{ github.repository }}-${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || (github.event_name == 'workflow_run' && 'auto-dispatch') || 'scan' }}
cancel-in-progress: false
env:
@@ -107,6 +108,10 @@ jobs:
return;
}
if (await mergeGate.shouldSkipMergeGateDispatch(github, owner, repo, prNumber, core)) {
return;
}
await github.rest.repos.createDispatchEvent({
owner,
repo,
@@ -288,8 +293,8 @@ jobs:
Review checklist:
1. Read AGENTS.md and the PR diff on the current branch.
2. Read all PR comments and reviews (FINAL architecture reviewer / Lead Engineer must have run).
3. Confirm SDK value gate: change strengthens praisonaiagents (no scope creep). The aim is a LIGHTWEIGHT AND POWERFUL package — BLOCK changes that add params/modules/exports duplicating existing Agent capabilities (instructions/backstory/tools/hooks/memory), or add knobs/fields with no live consumer, i.e. scope creep for the sake of it.
4. Repo routing: BLOCK if TypeScript/JavaScript SDK changes in src/praisonai-ts/ (→ praisonai-js + sync workflow); agent-callable tools (→ PraisonAI-Tools); lifecycle plugins (→ PraisonAI-Plugins); or optional sandbox backends (→ PraisonAI-Plugins via `praisonai.sandbox`) were added to praisonaiagents/ instead of the external repo.
3. Confirm SDK value gate: change strengthens the relevant SDK (Python: praisonaiagents/praisonai; TypeScript: src/praisonai-ts/) — no scope creep. The aim is a LIGHTWEIGHT AND POWERFUL package — BLOCK changes that add params/modules/exports duplicating existing Agent capabilities (instructions/backstory/tools/hooks/memory), or add knobs/fields with no live consumer, i.e. scope creep for the sake of it. FINAL architecture review must include the Phase 1 table with an explicit **SDK value** row.
4. Repo routing: BLOCK if agent-callable tools (→ PraisonAI-Tools), lifecycle plugins (→ PraisonAI-Plugins), or optional sandbox backends (→ PraisonAI-Plugins via `praisonai.sandbox`) were added to praisonaiagents/ instead of the external repo.
5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
6. If agent.py adds >100 lines or new Agent __init__ params, respond MERGE_GATE_VERDICT: BLOCK (manual review required).
7. BLOCK if labels security/breaking-change/needs-manual-review/release, sensitive paths (.github/workflows, auth, pyproject.toml), secrets in diff, PR >800 lines or >30 files, or SDK changes without tests. Label `merge-gate-ci-only` exempts CI-only changes under `.github/workflows/`, `.github/actions/`, or merge-gate scripts.
+17 -27
View File
@@ -70,21 +70,9 @@ jobs:
if (content.includes('documentation') || content.includes('docs')) {
labels.push('documentation');
}
const isPraisonaiJsFocus =
content.includes('praisonai-js') ||
content.includes('praisonai-ts') ||
content.includes('src/praisonai-ts') ||
content.includes('typescript sdk') ||
content.includes('javascript sdk') ||
(content.includes('typescript') && content.includes('praisonai')) ||
(content.includes('javascript') && content.includes('praisonai')) ||
(content.includes('npm') && content.includes('praisonai') && !content.includes('pypi'));
if (isPraisonaiJsFocus || content.includes('typescript') || content.includes('javascript') || content.includes('npm')) {
if (content.includes('typescript') || content.includes('javascript') || content.includes('npm')) {
labels.push('javascript');
}
if (isPraisonaiJsFocus) {
labels.push('praisonai-js');
}
// Apply labels
if (labels.length > 0) {
@@ -112,18 +100,19 @@ jobs:
'',
'_Bug and enhancement reports are auto-triaged with Claude. Questions and other issues: a maintainer can comment `@claude` to trigger analysis._',
'',
'**Routing:** TypeScript/JavaScript SDK → [praisonai-js](https://github.com/MervinPraison/praisonai-js) (canonical; monorepo `src/praisonai-ts/` is synced from there). Agent-callable tools → [PraisonAI-Tools](https://github.com/MervinPraison/PraisonAI-Tools); lifecycle plugins (tracing/hooks/guardrails) → [PraisonAI-Plugins](https://github.com/MervinPraison/PraisonAI-Plugins).'
'**Routing:** Agent-callable tools → [PraisonAI-Tools](https://github.com/MervinPraison/PraisonAI-Tools); lifecycle plugins (tracing/hooks/guardrails) → [PraisonAI-Plugins](https://github.com/MervinPraison/PraisonAI-Plugins). TypeScript/JavaScript SDK fixes land in `src/praisonai-ts/` in this repo.'
].join('\n')
});
}
// --- Auto-trigger Claude for owner issues and actionable external bug/enhancement reports ---
// --- Auto-trigger Claude for bug/enhancement reports ---
const titleLower = (issue.title || '').toLowerCase();
const bodyRaw = issue.body || '';
const hasBugMarker = /#?\s*\[bug\]/i.test(issue.title || '') || /#?\s*\[bug\]/i.test(bodyRaw);
const hasActionableTitle = /^(\[[^\]]+\]|bug:|fix[:(]|feat:|ux:|enhancement:)/i.test(titleLower);
const autoClaude = isOwner || (hasActionableTitle && (
labels.includes('bug') ||
(labels.includes('enhancement') && !labels.includes('question'))
));
const isBugReport = labels.includes('bug') || hasBugMarker;
const isEnhancement = labels.includes('enhancement') && !labels.includes('question');
const autoClaude = isOwner || isBugReport || (hasActionableTitle && isEnhancement);
if (autoClaude) {
await github.rest.issues.addLabels({
issue_number: issue.number,
@@ -305,7 +294,7 @@ jobs:
STEP 1 — READ GUIDELINES:
Read AGENTS.md to understand the architecture rules.
SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or src/praisonai-ts in this repo — TypeScript/JavaScript SDK work belongs in MervinPraison/praisonai-js (STEP 3-ALT).
SCOPE: Python SDK (praisonaiagents, praisonai) and TypeScript SDK (src/praisonai-ts/) live in this monorepo. Do NOT modify src/praisonai-rust. For TS issues read src/praisonai-ts/AGENTS.md.
AIM: This package must stay LIGHTWEIGHT AND POWERFUL. Do a CRITICAL REVIEW at each stage (issue triage, fix planning, PR review/implementation) and reject scope creep for the sake of adding features. Prefer existing Agent capabilities (instructions/backstory/tools/hooks/memory) over new params/modules/exports. Even when fixing a raised issue, keep the fix minimal and lightweight — do NOT expand the surface area or add knobs with no live consumer.
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before writing code):
@@ -317,8 +306,8 @@ jobs:
4. Documentation (PraisonAIDocs): Documentation pages, guides, API references.
5. Plugins → MervinPraison/PraisonAI-Plugins: framework lifecycle extensions (tracing, logging, metrics, hooks, guardrails). Not agent tools — use STEP 3-ALT.
6. Sandbox backends (optional/niche, e.g. Capsule) → PraisonAI-Plugins via `praisonai.sandbox` entry point; core keeps protocol/config/manager only.
7. TypeScript/JavaScript SDK → MervinPraison/praisonai-js: canonical npm SDK repo. Do NOT implement in src/praisonai-ts/ here — clone praisonai-js and use STEP 3-ALT. After merge there, run **Sync to PraisonAI Monorepo** workflow to update src/praisonai-ts/.
For items routed to an EXTERNAL repository (Tools, Docs, Plugins, praisonai-js), follow STEP 3-ALT below.
7. TypeScript/JavaScript SDK → src/praisonai-ts/ in this monorepo (not praisonaiagents/).
For items routed to an EXTERNAL repository (Tools, Docs, Plugins), follow STEP 3-ALT below.
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory). If it does, reject the change as scope creep and prefer the existing capability — the aim is lightweight and powerful, not more knobs.
- Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
@@ -331,7 +320,7 @@ jobs:
${{ steps.check_fork.outputs.is_pr == 'true' && (steps.check_fork.outputs.is_fork != 'true' || steps.check_fork.outputs.can_push_fork == 'true') && steps.check_fork.outputs.merge_state != 'DIRTY' && format('STEP 3 — IMPLEMENT ON EXISTING PR (branch: {0}, PR #{1}{2}):\n- DO NOT create a new branch or PR\n- git checkout {0}\n- Implement minimal, focused fixes from reviewer feedback\n- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper\n- Keep changes small and backward-compatible\n- Commit: git add -A && git commit -m "fix: <description>"\n- Push: git push origin {0}\n- Comment a summary of exact files modified and what you skipped\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number, steps.check_fork.outputs.can_push_fork == 'true' && format(', fork: {0}', steps.check_fork.outputs.fork_repo) || '') || '' }}
${{ steps.check_fork.outputs.is_pr != 'true' && 'STEP 3 — IMPLEMENT (for changes in THIS repo — PraisonAI):\n- Create a fix branch: git checkout -b claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\n- Implement a minimal, focused fix\n- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper\n- Keep changes small and backward-compatible\n- Commit: git add -A && git commit -m "fix: <description> (fixes #$ISSUE_NUMBER)"\n- Push: git push origin claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\nCRITICAL: After pushing, you MUST immediately run: gh pr create --title "fix: <title>" --body "Fixes #$ISSUE_NUMBER" --head claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\nDo NOT stop after pushing. Do NOT say "manually create a PR". Always create the PR automatically.\n' || '' }}
STEP 3-ALT — IMPLEMENT IN EXTERNAL REPO (for PraisonAI-Tools, PraisonAIDocs, PraisonAI-Plugins, praisonai-js):
STEP 3-ALT — IMPLEMENT IN EXTERNAL REPO (for PraisonAI-Tools, PraisonAIDocs, PraisonAI-Plugins):
When work must happen in a different repository, follow these steps EXACTLY. Do NOT attempt to use `cd`, as directory state is not preserved. Use `git -C` for all commands.
a) Clone the repository:
gh repo clone MervinPraison/<REPO_NAME> /tmp/<REPO_NAME>
@@ -349,11 +338,11 @@ jobs:
gh pr create -R MervinPraison/<REPO_NAME> --head claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d) --title "feat: <title>" --body "Fixes MervinPraison/PraisonAI#$ISSUE_NUMBER
<body>"
h) Comment on THIS PraisonAI issue with the external PR URL. For praisonai-js: note that after merge, a maintainer runs **Sync to PraisonAI Monorepo** on praisonai-js (Actions → sync workflow) to update src/praisonai-ts/.
h) Comment on THIS PraisonAI issue with the external PR URL.
STEP 4 — TEST:
- For SDK changes: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
- For praisonai-js: cd /tmp/praisonai-js && npm install && npm run build && npm test
- For Python SDK changes: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
- For TypeScript SDK changes: cd src/praisonai-ts && npm install && npm run build && npm test
- For docs changes: verify files exist and are valid markdown/MDX
- Ensure no regressions
@@ -387,9 +376,10 @@ jobs:
steps.check_fork.outputs.is_pr != 'true' &&
(github.event_name == 'issues' || github.event_name == 'issue_comment') &&
github.event.issue != null
continue-on-error: true
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
github-token: ${{ steps.app-token.outputs.token }}
script: |
const path = require('path');
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
+84
View File
@@ -122,10 +122,94 @@ jobs:
# let a failure be a failure.
run: command -v cargo-tauri >/dev/null || cargo install tauri-cli --version '^2' --locked
- name: Set the desktop version from the release tag
shell: bash
env:
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }}
run: node src/praisonai-desktop/tools/set-release-version.mjs "$RELEASE_TAG"
- name: Build the bundle
working-directory: src/praisonai-desktop/src-tauri
run: cargo tauri build --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
- name: The Windows binary must carry the release version
if: runner.os == 'Windows'
shell: pwsh
working-directory: src/praisonai-desktop/src-tauri
env:
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }}
run: |
$expected = $env:RELEASE_TAG -replace '^v', ''
$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"
}
# 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
if: runner.os == 'macOS'
working-directory: src/praisonai-desktop/src-tauri
run: |
set -euo pipefail
dmg="$(find "target/${{ matrix.target }}/release/bundle" -name '*.dmg' -print -quit)"
[ -n "$dmg" ] || { echo "no .dmg was produced"; exit 1; }
# Verify inside the DMG, not the .app beside it.
#
# Tauri deletes the app bundle once the DMG is built -- the build log
# says so plainly ("Cleaning .../bundle/macos/PraisonAI.app") -- so a
# gate that looked for the .app found nothing and failed every macOS
# leg. Reading the DMG is also the better check: it is the artifact
# people download, and the one that was broken.
#
# v4.7.2 shipped a DMG that macOS called "damaged and can't be
# opened" on every Mac. The cause was not the missing Developer ID:
# the only signature was the one rustc's linker puts on the bare
# executable, whose CodeDirectory declares a resource seal that an
# unsigned bundle does not have --
#
# Sealed Resources=none
# code has no resources but signature indicates they must be present
#
# That is an *invalid* signature, not an unsigned one, and macOS
# reports invalid as "damaged". Right-click -> Open cannot bypass it,
# so the instructions shipped alongside it could not work either.
mnt="$(mktemp -d)"
hdiutil attach -nobrowse -readonly -quiet -mountpoint "$mnt" "$dmg"
trap 'hdiutil detach "$mnt" -quiet || true' EXIT
app="$(find "$mnt" -maxdepth 1 -name '*.app' -print -quit)"
[ -n "$app" ] || { echo "the DMG contains no .app"; ls -la "$mnt"; exit 1; }
echo "verifying $app"
codesign --verify --deep --strict --verbose=2 "$app"
# Printed, not asserted: --verify already fails on the exact bundle
# that shipped, so a second check for the same thing would only be a
# second way to say it. This is here to make the log readable.
codesign -dv --verbose=2 "$app" 2>&1 | grep -E 'Sealed Resources|flags=' || true
- name: Name the artifact for the platform it runs on
id: artifact
shell: bash
+212
View File
@@ -0,0 +1,212 @@
# The mobile package's tests.
#
# Written at the same time as the package, deliberately. praisonai-desktop's
# suite existed and ran nowhere for months because no workflow referenced it,
# and a package whose tests never run in CI is a package whose tests are
# decoration -- the 523 here would go stale silently.
#
# Four gates, and they catch different things:
#
# typecheck -- the boundary between erasable and non-erasable TypeScript.
# node:test strips types rather than compiling, so an enum or
# a constructor parameter property is a runtime parse error at
# import; `erasableSyntaxOnly` turns that into a typecheck
# failure at the file that caused it.
# boundaries -- the two seams. Only engines/src/praisonai-ts may import
# 'praisonai'; only adapters/src/tauri may import @tauri-apps.
# These are the whole reason the package is swappable, and a
# rule enforced only by review stops being enforced.
# test -- the suite itself.
# bundle gate -- webview compatibility. This is the one a normal test run
# CANNOT do: node:test runs in Node, where a `crypto` import
# and a top-level `process.env` both work fine, and both kill
# a webview bundle at import time.
name: mobile
on:
push:
branches: [main]
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: *watched
concurrency:
group: mobile-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
working-directory: src/praisonai-mobile
jobs:
check:
name: check (node ${{ matrix.node }})
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
# 22.18 is the floor declared in package.json -- tested because a
# declared floor nobody runs is a guess. 24 is the forward check:
# native type-stripping is the feature this package depends on most,
# and it is exactly the kind of thing a major bump changes.
node: ['22.18', '24']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
cache-dependency-path: src/praisonai-mobile/package-lock.json
- run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Layer boundaries
run: npm run boundaries
- name: Tests
run: npm test
- name: Bundle gate fixtures
# Runs the gate's own suite, which proves each check can FAIL -- a
# build check that has only ever seen clean input is a check nobody
# knows the state of.
run: npm run test:bundle
upstream-parity:
name: upstream parity
# Its own job so drift from the real Agent is visible in the checks list
# rather than buried in another job's log.
#
# It needs only THIS package's install. praisonai-ts's own dependencies are
# neither needed nor installable -- `npm install` there fails with an
# ERESOLVE peer conflict (bedrock-agentcore -> @strands-agents/sdk) -- and
# every member the check looks at is declared locally in simple.ts. That is
# verified rather than assumed: with praisonai-ts's node_modules moved
# aside, injected drift is still caught as TS2741.
runs-on: ubuntu-22.04
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
- run: npm ci
- name: Does the real Agent still satisfy PraisonAgent?
# Skips loudly if the praisonai-ts checkout is missing, rather than
# passing quietly -- a check that silently stops covering anything is
# worse than no check.
run: npm run check:upstream
shell:
name: rust shell (${{ matrix.os }})
# The Rust crate had 14 tests and no job running them -- the same failure
# this workflow's header says it was created to prevent, one language over.
#
# Both platforms, because the crate is cfg-heavy: the back-gesture fallback
# is #[cfg(target_os = "android")] / ios / not(mobile), and a cfg mistake
# compiles perfectly on whichever host you happened to try.
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, 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:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: src/praisonai-mobile/src-tauri
- name: Install the Linux build dependencies
if: runner.os == 'Linux'
working-directory: .
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev librsvg2-dev libxdo-dev libssl-dev patchelf
# generate_context! expands frontendDist (../dist) at COMPILE time, and
# dist/ is gitignored -- absent on a fresh checkout. Without this the
# crate cannot compile and neither test nor clippy below ever runs.
- name: Build the webview (creates ../dist for generate_context!)
working-directory: src/praisonai-mobile
run: |
npm ci
npm run build
# The pure-logic tests: back-gesture arbitration, the lifecycle mapping,
# and the event-name contract. None of them needs a phone.
- name: Test
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
@@ -0,0 +1,62 @@
# Can praisonai-ts still be loaded in a webview?
#
# Issue #4437 was this failing: `crypto` and `events` were static imports on
# the Agent graph, so a webview bundle died at IMPORT time with no error
# boundary and a blank screen. PR #4438 fixed it, and nothing stopped it
# coming back.
#
# `npm test` cannot catch a regression here, structurally: it runs in Node,
# where every one of these imports works perfectly. One `import { x } from
# "fs"` added to a file the Agent happens to reach would silently un-ship the
# mobile app, and the first signal would be a blank screen on a device.
#
# Deliberately narrow. It runs only when praisonai-ts changes, installs only
# what it needs, and asserts one thing.
name: praisonai-ts webview
on:
push:
branches: [main]
paths:
- 'src/praisonai-ts/src/**'
- 'src/praisonai-ts/package.json'
- 'src/praisonai-ts/scripts/webview-gate.mjs'
- '.github/workflows/praisonai-ts-webview.yml'
pull_request:
paths:
- 'src/praisonai-ts/src/**'
- 'src/praisonai-ts/package.json'
- 'src/praisonai-ts/scripts/webview-gate.mjs'
- '.github/workflows/praisonai-ts-webview.yml'
concurrency:
group: praisonai-ts-webview-${{ github.ref }}
cancel-in-progress: true
jobs:
webview:
name: loadable in a webview
runs-on: ubuntu-22.04
defaults:
run:
working-directory: src/praisonai-ts
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22.18'
# `npm install`, not `npm ci`: praisonai-ts gitignores its lockfile
# (src/praisonai-ts/.gitignore), so `npm ci` has nothing to install from.
# `--legacy-peer-deps` because the dependency tree does not otherwise
# resolve -- bedrock-agentcore wants a peer of @strands-agents/sdk that
# is not installed. That is pre-existing and out of scope here; without
# the flag this job fails on something unrelated to what it checks.
- name: Install
run: npm install --no-audit --no-fund --legacy-peer-deps
# Build first: the gate must see what actually SHIPS. The esm-shim adds
# a CJS banner that does not exist in the TypeScript sources, and that
# banner is what broke the published entry while the sources passed.
- name: Build
run: npm run build
- name: Check
run: npm run check:webview
+106
View File
@@ -0,0 +1,106 @@
name: Sync to praisonai-js
# Canonical TS source: src/praisonai-ts/ in this monorepo.
# This workflow pushes the mirror to MervinPraison/praisonai-js (npm repo).
on:
workflow_dispatch:
inputs:
target_branch:
description: 'Target branch on MervinPraison/praisonai-js'
required: false
default: main
type: string
mode:
description: 'pr = open pull request (default); commit = push directly to target branch'
required: false
default: pr
type: choice
options:
- pr
- commit
permissions:
contents: read
jobs:
sync:
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
TARGET_REPO: MervinPraison/praisonai-js
SOURCE_PATH: src/praisonai-ts
TARGET_BRANCH: ${{ inputs.target_branch }}
steps:
- name: Validate token
run: |
if [ -z "${GH_TOKEN:-}" ]; then
echo "GH_TOKEN secret is required (PAT with repo write access to praisonai-js)."
exit 1
fi
- name: Checkout PraisonAI monorepo
uses: actions/checkout@v4
- name: Checkout praisonai-js
uses: actions/checkout@v4
with:
repository: ${{ env.TARGET_REPO }}
token: ${{ secrets.GH_TOKEN }}
path: praisonai-js-target
ref: ${{ env.TARGET_BRANCH }}
- name: Sync monorepo path into praisonai-js
run: |
set -euo pipefail
rsync -a --delete \
--exclude '.git/' \
--exclude 'node_modules/' \
--exclude 'dist/' \
--exclude 'coverage/' \
--exclude '.DS_Store' \
--exclude '*.tsbuildinfo' \
"${SOURCE_PATH}/" praisonai-js-target/
- name: Push changes
working-directory: praisonai-js-target
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Preserve praisonai-js automation (rsync source has no .github/)
if git diff --quiet; then
echo "No changes — praisonai-js already matches ${SOURCE_PATH} on monorepo main."
exit 0
fi
git add -A
msg="sync: update from monorepo ${SOURCE_PATH}"
if [ "${{ inputs.mode }}" = "commit" ]; then
git commit -m "$msg"
git push origin "HEAD:${TARGET_BRANCH}"
echo "Committed and pushed directly to ${TARGET_BRANCH} on praisonai-js."
exit 0
fi
branch="sync/monorepo-praisonai-ts-$(date -u +%Y%m%d-%H%M%S)"
git checkout -b "$branch"
git commit -m "$msg"
git push origin "$branch"
gh pr create \
--repo "$TARGET_REPO" \
--base "$TARGET_BRANCH" \
--head "$branch" \
--title "$msg" \
--body "$(cat <<EOF
Automated sync from [\`MervinPraison/PraisonAI\`](https://github.com/MervinPraison/PraisonAI) \`${SOURCE_PATH}/\` into the npm mirror repo.
Canonical development is in the monorepo; this PR updates the public \`praisonai-js\` checkout.
Excludes from rsync source: \`node_modules/\`, \`dist/\`, \`coverage/\`. \`.github/\` on praisonai-js is unchanged.
EOF
)"
echo "Opened PR on ${TARGET_REPO} (${branch} → ${TARGET_BRANCH})."
+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]>"
+15 -2
View File
@@ -7,9 +7,22 @@ You are working on the PraisonAI project.
- Be concise and helpful in responses
- Test implementation thoroughly
- Ensure backward compatibility with existing APIs
- Follow protocol-driven design across the nine packages: core protocols in `praisonaiagents/`, agentic terminal CLI in `praisonai-code/`, bots/gateway in `praisonai-bot/`, LLM fine-tuning + agent training in `praisonai-train/`, browser automation in `praisonai-browser/`, MCP server host in `praisonai-mcp/`, sandbox backends in `praisonai-sandbox/`, deployment in `praisonai-deploy/`, integrations/serve/dashboard in the `praisonai/` wrapper
- Follow protocol-driven design across the nine Python PyPI packages plus the TypeScript SDK: core protocols in `praisonaiagents/`, agentic terminal CLI in `praisonai-code/`, bots/gateway in `praisonai-bot/`, LLM fine-tuning + agent training in `praisonai-train/`, browser automation in `praisonai-browser/`, MCP server host in `praisonai-mcp/`, sandbox backends in `praisonai-sandbox/`, deployment in `praisonai-deploy/`, integrations/serve/dashboard in the `praisonai/` wrapper, TypeScript/JavaScript SDK in `src/praisonai-ts/` (`npm: praisonai`)
- **TypeScript review routing:** For TS/JS issues and PRs, read [`src/praisonai-ts/AGENTS.md`](src/praisonai-ts/AGENTS.md) (not just this file). Canonical source is `src/praisonai-ts/` in this monorepo; [MervinPraison/praisonai-js](https://github.com/MervinPraison/praisonai-js) is the npm mirror only (sync: monorepo → praisonai-js via workflow **Sync to praisonai-js**). Do not implement TS fixes in praisonai-js for PraisonAI issues.
- Preserve old `praisonai.*` import paths via shims when moving code between packages (see §2.3 in `src/praisonai-agents/AGENTS.md`; shim helpers in `src/praisonai/praisonai/cli/_shim.py`)
- Package boundaries and dependency rules: `ARCHITECTURE.md` §2 (Tier 2 packages must never PyPI-depend on the wrapper; cross-tier access goes through lazy `_*_bridge` modules)
- Boundary manifests: `src/praisonai/tests/PRAISONAI_BOT_MANIFEST.md` (C9), `src/praisonai/tests/PRAISONAI_TRAIN_MANIFEST.md` (C10), `src/praisonai/tests/PRAISONAI_BROWSER_MANIFEST.md` (C11), `src/praisonai/tests/PRAISONAI_MCP_MANIFEST.md` (C12), `src/praisonai/tests/PRAISONAI_SANDBOX_MANIFEST.md` (C13), `src/praisonai/tests/PRAISONAI_DEPLOY_MANIFEST.md` (C14)
- When reviewing a PR or an issue, evaluate whether the change addresses a framework concern or a user goal, and design its surface (params, naming, defaults) accordingly
- The aim of this package is to stay **lightweight and powerful**. Do a critical review at each stage — when triaging an issue, when planning a fix, and when reviewing/implementing a PR. Reject scope creep for the sake of adding features: if a capability already exists (e.g. via existing Agent params like `instructions`/`backstory`/`tools`/`hooks`/`memory`), prefer it over a new API surface. A change must genuinely strengthen the SDK (simpler, more robust, more user-friendly) — do not add knobs, params, modules, or exports that have no live consumer or that merely duplicate existing behaviour.
- The aim of this package is to stay **lightweight and powerful**. Do a critical review at each stage — when triaging an issue, when planning a fix, and when reviewing/implementing a PR. Reject scope creep for the sake of adding features: if a capability already exists (e.g. via existing Agent params like `instructions`/`backstory`/`tools`/`hooks`/`memory`), prefer it over a new API surface. A change must genuinely strengthen the SDK (simpler, more robust, more user-friendly) — do not add knobs, params, modules, or exports that have no live consumer or that merely duplicate existing behaviour.
## Issue and PR routing
| Area | Where to implement | AGENTS.md to read | Tests |
|------|-------------------|-------------------|-------|
| Python core SDK | `src/praisonai-agents/praisonaiagents/` | `src/praisonai-agents/AGENTS.md` | `pytest` in `src/praisonai-agents/tests/` |
| Python wrapper / CLI | `src/praisonai/`, `src/praisonai-code/`, etc. | `src/praisonai-agents/AGENTS.md` | `pytest` under `src/praisonai/tests/` |
| **TypeScript / JavaScript SDK** | **`src/praisonai-ts/`** | **`src/praisonai-ts/AGENTS.md`** | `cd src/praisonai-ts && npm run build && npm test` |
| Agent-callable tools | [PraisonAI-Tools](https://github.com/MervinPraison/PraisonAI-Tools) | — | repo tests |
| Lifecycle plugins | [PraisonAI-Plugins](https://github.com/MervinPraison/PraisonAI-Plugins) | — | repo tests |
| Documentation | [PraisonAIDocs](https://github.com/MervinPraison/PraisonAIDocs) | — | `nav-check` |
| npm mirror (read-only for fixes) | [praisonai-js](https://github.com/MervinPraison/praisonai-js) | mirror of `src/praisonai-ts/AGENTS.md` | CI on praisonai-js |
+6 -7
View File
@@ -7,21 +7,20 @@ Thank you for your interest! Here's how to get started:
3. Make your changes
4. Submit a pull request
## Where to report issues (hub intake)
## Where to report issues
| Topic | Report here | Where fixes land |
|-------|-------------|------------------|
| Python SDK, CLI, wrapper | **This repo** ([PraisonAI](https://github.com/MervinPraison/PraisonAI)) | `src/praisonai-agents/`, `src/praisonai/` |
| TypeScript / JavaScript SDK (`npm install praisonai`) | **This repo** (preferred) or [praisonai-js](https://github.com/MervinPraison/praisonai-js) | [praisonai-js](https://github.com/MervinPraison/praisonai-js) → synced to `src/praisonai-ts/` |
| Cross-language parity | **This repo** | Often both Python and praisonai-js PRs |
| Python SDK, CLI, wrapper | **This repo** | `src/praisonai-agents/`, `src/praisonai/` |
| TypeScript / JavaScript (`npm install praisonai`) | **This repo** | `src/praisonai-ts/` |
| Cross-language parity | **This repo** | Often both Python and TypeScript paths |
| Agent-callable tools | **This repo** | [PraisonAI-Tools](https://github.com/MervinPraison/PraisonAI-Tools) |
| Lifecycle plugins (tracing, hooks, guardrails) | **This repo** | [PraisonAI-Plugins](https://github.com/MervinPraison/PraisonAI-Plugins) |
You do **not** need to re-file TypeScript issues on praisonai-js — report on PraisonAI and automation routes fixes to the canonical TS repo.
## Guidelines
- Follow existing code style
- Add tests for new features
- Update documentation as needed
- TypeScript changes: implement in [praisonai-js](https://github.com/MervinPraison/praisonai-js), then run **Sync to PraisonAI Monorepo** workflow to update `src/praisonai-ts/`
- TypeScript changes: implement in `src/praisonai-ts/` and run `npm test` there
- After merge, sync the npm mirror: `gh workflow run "Sync to praisonai-js" --repo MervinPraison/PraisonAI`
+1 -3
View File
@@ -8,9 +8,7 @@ PraisonAI takes security seriously. We appreciate responsible disclosure from re
|---------|----------|-------------|
| `praisonaiagents` | [PyPI](https://pypi.org/project/praisonaiagents/) | `src/praisonai-agents/` |
| `praisonai` | [PyPI](https://pypi.org/project/praisonai/) | `src/praisonai/` |
| `praisonai` | [npm](https://www.npmjs.com/package/praisonai) | [praisonai-js](https://github.com/MervinPraison/praisonai-js) (canonical); mirrored at `src/praisonai-ts/` |
Report npm/TypeScript vulnerabilities on **PraisonAI** (hub intake) or [praisonai-js](https://github.com/MervinPraison/praisonai-js). Fixes land in praisonai-js first.
| `praisonai` | [npm](https://www.npmjs.com/package/praisonai) | `src/praisonai-ts/` |
Report issues against the **specific package** where the vulnerability exists. A platform-only issue does not necessarily affect the core SDK, and vice versa.
+5 -2
View File
@@ -68,6 +68,8 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">query</a>(question: str, **kwargs) -> 'RAGResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rag</a>() -> Optional[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rag_query</a>(question: str, **kwargs) -> 'RAGResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">reasoning_effort</a>() -> Optional[str]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">reasoning_effort</a>(value: Optional[str]) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">redo</a>() -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">refresh_tools</a>() -> List[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">remove_mcp_server</a>(name: str) -> bool</code>
@@ -779,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
@@ -904,6 +906,7 @@ Methods:
* <code title="cli">praisonai schedule remove <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule restart <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule resume <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule run <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule runs <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule schedule-callback <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule start <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
@@ -923,7 +926,7 @@ Methods:
Types/Exports:
```ts
export { Agent, AgentTeam, Agents, PraisonAIAgents, Router } from "./agent";
export type { AgentTeamConfig, PraisonAIAgentsConfig, SimpleAgentConfig, SimpleRouteConfig, SimpleRouterConfig } from "./agent";
export type { AgentEvent, AgentMessage, AgentStreamOptions, AgentTeamConfig, PraisonAIAgentsConfig, SimpleAgentConfig, SimpleRouteConfig, SimpleRouterConfig, StopReason } from "./agent";
export { AudioAgent, createAudioAgent } from "./agent/audio";
export type { AudioAgentConfig, AudioProvider, AudioSpeakOptions, AudioSpeakResult, AudioTranscribeOptions, AudioTranscribeResult } from "./agent/audio";
export { ContextAgent, createContextAgent } from "./agent/context";
-10
View File
@@ -1,10 +0,0 @@
# Deployment infra has moved
Deployment assets are no longer at the monorepo root.
| Asset | New location |
|-------|----------------|
| Gateway Helm chart | [`src/praisonai-bot/infra/helm/praisonai-gateway/`](../src/praisonai-bot/infra/helm/praisonai-gateway/) |
| Agents API Helm, Compose, Starters | [`src/praisonai-deploy/infra/`](../src/praisonai-deploy/infra/) |
See [`src/praisonai-deploy/infra/README.md`](../src/praisonai-deploy/infra/README.md).
+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.1" \
"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.1" \
"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.1" \
"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()
+1 -1
View File
@@ -15,4 +15,4 @@ if completed.status == "completed":
# List all videos
videos = agent.list()
# Models: openai/sora-2, gemini/veo-3.0-generate-preview, azure/sora-2
# Models: openai/sora-2, gemini/veo-3.1-generate-preview, azure/sora-2
+14
View File
@@ -207,6 +207,7 @@ and features (capabilities, bots, framework adapters) — use
| 2b | `praisonai-bot` | Bots, gateway, channel CLI, OS daemon |
| 2c | `praisonai-train` | LLM fine-tuning (Unsloth), agent training, `train` CLI |
| 3 | `praisonai` | `framework_adapters/`, capabilities, serve orchestration, dashboard |
| 1-TS | `praisonai-ts` | TypeScript/JavaScript SDK (`npm: praisonai`); canonical path `src/praisonai-ts/` |
**Cross-tier rule:** `praisonai-code` must not declare a PyPI dependency on `praisonai`. Use
[`praisonai_code._wrapper_bridge`](../praisonai-code/praisonai_code/_wrapper_bridge.py) for lazy wrapper access.
@@ -222,6 +223,17 @@ from `praisonai_train.cli.commands.*` (`train`, C10); `_WRAPPER_RESIDENT_COMMAND
Full boundary doc: [`src/praisonai/tests/C7.1_BOUNDARIES.md`](../praisonai/tests/C7.1_BOUNDARIES.md).
### 2.5 TypeScript SDK (`praisonai-ts`)
Python guidelines in this file do **not** replace the TypeScript SDK guide. For any issue, PR, or review touching `src/praisonai-ts/`:
1. Read [`src/praisonai-ts/AGENTS.md`](../praisonai-ts/AGENTS.md) — architecture, parity, triage, and PR review rules.
2. Implement fixes in **`src/praisonai-ts/`** in this monorepo (not in `praisonaiagents/`).
3. Verify with `cd src/praisonai-ts && npm install && npm run build && npm test`.
4. After merge, maintainers sync the npm mirror: `gh workflow run "Sync to praisonai-js" --repo MervinPraison/PraisonAI`.
[MervinPraison/praisonai-js](https://github.com/MervinPraison/praisonai-js) is the public npm checkout; direction is **monorepo → praisonai-js** only. Claude merge gate and `@claude` review TS changes under `src/praisonai-ts/` using the TS AGENTS.md criteria (lightweight, type-safe, Python parity where applicable).
---
## 3. Core SDK Architecture (praisonaiagents)
@@ -876,6 +888,8 @@ from praisonaiagents import Workflow, Route, Parallel, Loop
| CLI backends | `praisonai_code/cli_backends/` |
| Back-compat shims | `praisonai/cli/_shim.py`, `praisonai/cli/main.py` |
| C5 regression tests | `praisonai/tests/unit/test_c5_backward_compat.py` |
| TypeScript SDK | `src/praisonai-ts/` — see `src/praisonai-ts/AGENTS.md` |
| npm mirror (sync only) | [MervinPraison/praisonai-js](https://github.com/MervinPraison/praisonai-js) |
---
+33 -33
View File
@@ -17,78 +17,78 @@ This report compares **Python SDK feature categories** against **Python document
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 420 |
| ✅ Agent | 22 | 53 | 17107 |
| ✅ AGUI | 1 | 2 | 511 |
| ✅ Agent | 22 | 56 | 17728 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 689 |
| ✅ Approval | 1 | 5 | 2764 |
| ✅ Approval | 1 | 7 | 3503 |
| ✅ Audio | 2 | 12 | 1000 |
| ✅ Auto Generation | 5 | 11 | 3542 |
| ✅ Auto Generation | 5 | 12 | 3747 |
| ✅ Autonomy | 3 | 7 | 2790 |
| ✅ Bots | 7 | 32 | 13616 |
| ✅ Bots | 7 | 33 | 13860 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 122 | 36991 |
| ✅ CLI | 5 | 123 | 37946 |
| ✅ Chunking | 2 | 2 | 420 |
| ✅ Citations | 2 | 1 | 202 |
| ✅ Code Execution | 2 | 14 | 4855 |
| ✅ Conditions | 1 | 3 | 1347 |
| ✅ Configuration | 3 | 8 | 3173 |
| ✅ Context Management | 16 | 36 | 13988 |
| ✅ Configuration | 3 | 8 | 3292 |
| ✅ Context Management | 16 | 36 | 14090 |
| ✅ Deep Research | 8 | 2 | 587 |
| ✅ Display | 6 | 3 | 741 |
| ✅ Embeddings | 6 | 23 | 2049 |
| ✅ Evaluation | 1 | 7 | 2920 |
| ✅ Events | 1 | 2 | 749 |
| ✅ Execution | 3 | 4 | 1606 |
| ✅ Evaluation | 1 | 8 | 3220 |
| ✅ Events | 1 | 2 | 767 |
| ✅ Execution | 3 | 4 | 1631 |
| ✅ Failover | 2 | 1 | 452 |
| ✅ Files | 2 | 7 | 2275 |
| ✅ Files | 2 | 7 | 2376 |
| ✅ Flow | 1 | 3 | 788 |
| ✅ Gateway | 7 | 82 | 30269 |
| ✅ Guardrails | 4 | 4 | 2228 |
| ✅ Gateway | 7 | 85 | 31517 |
| ✅ Guardrails | 4 | 4 | 2288 |
| ✅ Handoffs | 11 | 6 | 2626 |
| ✅ Hooks | 2 | 9 | 4509 |
| ✅ Hooks | 2 | 9 | 4690 |
| ✅ Image | 1 | 11 | 1255 |
| ✅ Knowledge | 4 | 16 | 4967 |
| ✅ LLM | 3 | 15 | 5372 |
| ✅ Knowledge | 4 | 17 | 5226 |
| ✅ LLM | 3 | 15 | 5395 |
| ✅ Loops | 4 | 5 | 1475 |
| ✅ MCP | 1 | 61 | 14160 |
| ✅ Memory | 6 | 18 | 7193 |
| ✅ MCP | 1 | 62 | 14436 |
| ✅ Memory | 6 | 19 | 7512 |
| ✅ OCR | 2 | 1 | 237 |
| ✅ Observability | 2 | 23 | 2811 |
| ✅ Optimizer | 1 | 2 | 792 |
| ✅ Output | 3 | 5 | 1265 |
| ✅ Output | 3 | 5 | 1273 |
| ✅ Parallel Execution | 3 | 2 | 497 |
| ✅ Planning | 6 | 6 | 1605 |
| ✅ Planning | 6 | 7 | 1825 |
| ✅ Plugins | 8 | 5 | 3120 |
| ✅ Prompts | 2 | 9 | 1971 |
| ✅ Providers | 1 | 56 | 7781 |
| ✅ Prompts | 2 | 9 | 2011 |
| ✅ Providers | 1 | 57 | 8197 |
| ✅ Query | 1 | 2 | 736 |
| ✅ RAG | 5 | 15 | 3118 |
| ✅ Realtime | 2 | 5 | 668 |
| ✅ Reflection | 3 | 3 | 730 |
| ✅ Reflection | 3 | 3 | 732 |
| ✅ Retrieval | 2 | 5 | 1042 |
| ✅ Routing | 1 | 2 | 430 |
| ✅ Sandbox | 5 | 7 | 3556 |
| ✅ Security | 1 | 3 | 3529 |
| ✅ Sessions | 4 | 19 | 7644 |
| ✅ Skills | 6 | 15 | 5638 |
| ✅ Sessions | 4 | 19 | 7648 |
| ✅ Skills | 6 | 16 | 5911 |
| ✅ Tasks | 2 | 6 | 2897 |
| ✅ Telemetry | 1 | 2 | 648 |
| ✅ Templates | 1 | 8 | 1663 |
| ✅ Tools | 12 | 144 | 43251 |
| ✅ Templates | 1 | 8 | 1667 |
| ✅ Tools | 12 | 145 | 44115 |
| ✅ Tracing | 3 | 2 | 139 |
| ✅ Vector Store | 1 | 12 | 1215 |
| ✅ Video | 2 | 6 | 649 |
| ✅ Vector Store | 1 | 12 | 1219 |
| ✅ 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 (42 docs, 6965 lines)
- ℹ️ Documents (1 docs, 787 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
@@ -626,6 +644,7 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
message_steering: Optional[Union[bool, 'MessageSteeringProtocol']] = False, # Real-time message steering during execution
sandbox: Optional[Union[bool, 'SandboxConfig']] = None, # Sandbox for safe code execution
retry: Optional[Union[bool, Dict[str, Any], 'RetryBackoffConfig']] = None, # Retry configuration with exponential backoff
reasoning_effort: Optional[str] = None, # Provider-portable reasoning effort: off|minimal|low|medium|high (Issue #4452)
**legacy_kwargs: Any, # Deprecated params (see _LEGACY_AGENT_PARAMS) consolidated into config objects
):
"""Initialize an Agent instance.
@@ -799,6 +818,10 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
# LLMConfig(fallback_models=[...])), so accept it here without exposing
# it in the signature and seed the local below.
_cloned_fallback_models = legacy_kwargs.pop("fallback_models", None)
# `thinking_budget` is a backward-compatible alias for `reasoning_effort`
# (Issue #4452); accept it here (like fallback_models) without exposing
# it in the signature so the unknown-kwarg guard below does not reject it.
_thinking_budget_alias = legacy_kwargs.pop("thinking_budget", None)
_unknown = set(legacy_kwargs) - _legacy_defaults.keys()
if _unknown:
# Unknown kwargs are rejected rather than swallowed, so a typo can
@@ -1026,7 +1049,11 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
planning_reasoning = False
policy = None
output_style = None
thinking_budget = None
# `thinking_budget` (popped above) is a backward-compatible alias for
# `reasoning_effort` (Issue #4452). Fold the legacy int budget / graded
# level into one internal effort value for the LLM request pipeline.
if reasoning_effort is None and _thinking_budget_alias is not None:
reasoning_effort = _thinking_budget_alias
skills_dirs = None
# ─────────────────────────────────────────────────────────────────────
@@ -1928,7 +1955,16 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
self.self_reflect = True if self_reflect is None else self_reflect
self.instructions = instructions
# Unique, per-instance scope id for skill auto-approval grants. The
# display ``name`` defaults to the literal "Agent" for every unnamed
# agent, so keying auto-approval by name alone would let one agent's
# skill grant leak onto every other unnamed agent in the same process
# (a cross-tenant privilege escalation). This id can never collide
# across unrelated instances.
import uuid as _uuid
self._approval_scope_id = f"{self.name}:{_uuid.uuid4().hex}"
# Resolve tool_config for tool execution settings
_tool_config = Agent._resolve_tool_config(
tool_config=tool_config
@@ -2083,6 +2119,11 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
'claude_memory': claude_memory,
**_retry_init_params,
}
# Forward the unified reasoning-effort control to every LLM-construction
# branch (Issue #4452). Only set when provided so unset stays a no-op and
# older dict/string branches that don't spread these kwargs are unaffected.
if reasoning_effort is not None:
self._llm_option_kwargs['reasoning_effort'] = reasoning_effort
# Panel (multi-model) descriptor: "panel:<name>" or {"provider": "panel"}.
# Resolved lazily into a PanelLLM; composes with the normal tool loop.
@@ -2204,7 +2245,14 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
self._llm_init_params = llm_params
self._using_custom_llm = True
self.llm = model_name
# Thread the unified reasoning-effort control into whichever LLM-init
# params the branch chain above produced (Issue #4452), so it reaches the
# LLM request pipeline regardless of how the model was specified. Only set
# when provided and not already present, keeping unset a zero-cost no-op.
if reasoning_effort is not None and getattr(self, "_llm_init_params", None):
self._llm_init_params.setdefault('reasoning_effort', reasoning_effort)
# Store fallback models for resilience (defensive copy to avoid external mutations)
self.fallback_models = list(fallback_models) if fallback_models else []
@@ -2743,7 +2791,28 @@ Your Goal: {self.goal}
self._auto_memory = auto_memory
self._policy = policy
self._output_style = output_style
self._thinking_budget = thinking_budget
# Backward-compatible: `thinking_budget` property mirrors the legacy int
# budget when supplied via the alias (Issue #4452); the unified effort is
# what actually drives the request pipeline via `_llm_init_params`.
self._thinking_budget = (
_thinking_budget_alias
if isinstance(_thinking_budget_alias, int)
else None
)
# Store the resolved unified reasoning-effort as a canonical graded level
# (off|minimal|low|medium|high) for session persistence. A legacy int
# ``thinking_budget`` alias is normalised to its nearest level so the
# persisted/queried value is always provider-portable (Issue #4452). The
# LLM request pipeline still accepts the raw value too, so behaviour is
# unchanged when unset.
if reasoning_effort is not None:
try:
from ..thinking.effort import normalize_effort
self._reasoning_effort = normalize_effort(reasoning_effort)
except Exception:
self._reasoning_effort = reasoning_effort
else:
self._reasoning_effort = None
# Context management (lazy loaded for zero overhead when disabled)
# Smart default: auto-enable context when tools are present
@@ -3135,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."""
@@ -3189,7 +3270,50 @@ Your Goal: {self.goal}
@thinking_budget.setter
def thinking_budget(self, value: Optional[int]) -> None:
self._thinking_budget = value
# `thinking_budget` is a backward-compatible alias for the unified
# `reasoning_effort` control (Issue #4452). Setting it must route through
# the same request-pipeline sync as `reasoning_effort`, otherwise the
# CLI's `--thinking` (which assigns this property after construction)
# stays dormant — the value would be stored but never emitted.
self._thinking_budget = value if isinstance(value, int) else None
self.reasoning_effort = value
@property
def reasoning_effort(self) -> Optional[str]:
"""Unified, provider-portable reasoning-effort level (Issue #4452).
One of ``off|minimal|low|medium|high``. Core translates it to the
target provider's native parameter (OpenAI/xAI ``reasoning_effort``,
Anthropic/Gemini extended-thinking budget) on the request path.
"""
return getattr(self, "_reasoning_effort", None)
@reasoning_effort.setter
def reasoning_effort(self, value: Optional[str]) -> None:
# Normalise to a canonical graded level so the stored/persisted value is
# always provider-portable, whether set as a level or a legacy int budget.
if value is not None:
try:
from ..thinking.effort import normalize_effort
value = normalize_effort(value)
except Exception:
pass
self._reasoning_effort = value
# Keep the live LLM-init params in sync so a post-construction change
# still reaches the request pipeline (mirrors thinking_budget aliasing).
if getattr(self, "_llm_init_params", None) is not None:
if value is None:
self._llm_init_params.pop('reasoning_effort', None)
else:
self._llm_init_params['reasoning_effort'] = value
# If an LLM instance has already been built (lazy cache), update it too so
# a post-construction change still takes effect without a rebuild.
instance = getattr(self, "_llm_instance", None)
if instance is not None:
try:
instance.reasoning_effort = value
except Exception:
pass
@property
def total_cost(self) -> float:
@@ -3649,10 +3773,21 @@ Summary:"""
One of ``"completed"`` (task finished), ``"max_steps"`` (the unified
step budget from ``ExecutionConfig.max_steps`` was reached and the run was
truncated) or ``"error"``. Lets CLI/CI callers branch on truncation
instead of parsing a magic string. Reads from whichever backend
(OpenAI-native or LiteLLM) executed the last turn.
truncated), a provider block/refusal/truncation
(``"content_filtered" | "refused" | "length_truncated"``) derived from the
LLM ``finish_reason``/refusal signal, or ``"error"``. Lets CLI/CI callers
branch on the terminal reason instead of parsing a magic string. Reads
from whichever backend (OpenAI-native or LiteLLM) executed the last turn.
"""
# OpenAI-native path records the finish-reason classification directly on
# the agent (see ``_extract_llm_response_content``); prefer it so a
# provider block/refusal isn't masked by a backend default of "completed".
own = getattr(self, '_last_stop_reason', None)
# Prefer a specific agent-owned provider block/refusal/truncation before
# any backend fallback so a stale backend reason (e.g. ``max_steps``)
# cannot mask the OpenAI-native classification recorded for this turn.
if own and own != "completed":
return own
# Read from the already-instantiated backends only. ``__openai_client``
# is the raw (name-mangled) attribute, never the lazy ``_openai_client``
# property, so this never triggers OpenAI client creation for
@@ -3662,8 +3797,10 @@ Summary:"""
if backend is None:
continue
reason = getattr(backend, '_last_stop_reason', None)
if reason:
if reason and reason != "completed":
return reason
if own:
return own
return "completed"
@property
@@ -4072,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:
@@ -4094,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)
@@ -6707,7 +6830,23 @@ Answer:"""
current_response = response_text
while retry_count <= self.max_guardrail_retries:
success, result, error = self._validate_with_guardrail(current_response)
# A string/LLMGuardrail guardrail fires a *blocking* LLM call inside
# _validate_with_guardrail. Offload it to a thread so it does not
# stall the event loop (and every other concurrently-running task
# under asyncio.gather), mirroring async_memory_mixin's
# _run_memory_in_thread executor-offload pattern.
loop = asyncio.get_event_loop()
# Preserve contextvars (trace emission, session context) across the
# executor thread so a custom guardrail sees the same contextual
# state as the synchronous path, matching every other
# run_in_executor call site in the SDK.
from ..trace.context_events import copy_context_to_callable
success, result, error = await loop.run_in_executor(
None,
copy_context_to_callable(
lambda: self._validate_with_guardrail(current_response)
),
)
if success:
logging.info(f"Agent {self.name}: Guardrail validation passed")
@@ -1244,6 +1244,13 @@ Your Goal: {self.goal}"""
f"Agent {self.name}: model returned no content "
f"(finish_reason={finish_reason!r}, refused={bool(refusal)})"
)
# Record a distinct terminal reason so the empty answer is
# actionable end-to-end (RunOutcome / CLI exit / --output
# json) instead of a silent "completed" with empty text.
from .run_outcome import classify_finish_reason
stop_reason = classify_finish_reason(finish_reason, refusal)
if stop_reason is not None:
self._last_stop_reason = stop_reason
return ""
except (AttributeError, IndexError, TypeError) as e:
logging.warning(
@@ -1631,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.
@@ -1747,6 +1795,12 @@ Your Goal: {self.goal}"""
def _chat_completion(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0, _fallback_index=0, cancel_token=None):
start_time = time.time()
# Reset the agent-level finish-reason classification at the start of each
# OpenAI-native turn so a provider block/refusal recorded on a previous
# run (see ``_extract_llm_response_content``) never leaks into this one.
# The LiteLLM path resets its own backend flag independently.
self._last_stop_reason = "completed"
# --- Proactive Context Budget Management (default-on) ---
# Analyzes token budget BEFORE LLM call and applies appropriate strategy
try:
@@ -1995,7 +2049,6 @@ Your Goal: {self.goal}"""
# Use structured error classification for all error types (replaces legacy heuristic checks)
from ..llm.error_classifier import classify_llm_error
from ..llm.retry_utils import jittered_backoff
model_name = self.llm if isinstance(self.llm, str) else "unknown"
session_id = getattr(self, '_session_id', 'unknown')
@@ -2082,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
@@ -2260,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(
@@ -2277,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
@@ -2487,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.
@@ -2565,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),
@@ -2947,7 +3020,14 @@ Your Goal: {self.goal}"""
from ..approval import get_approval_registry
registry = get_approval_registry()
agent_name = getattr(self, "display_name", getattr(self, "name", None))
# Key the grant by the unique per-instance scope id (not the
# display name, which defaults to "Agent" for every unnamed
# agent and would leak this grant onto other agents in the
# same process). Falls back to name/display_name for objects
# without a scope id.
agent_name = getattr(self, "_approval_scope_id", None) or getattr(
self, "display_name", getattr(self, "name", None)
)
if agent_name: # Only approve if we have a stable agent identifier
for _tn in tool_names:
try:
@@ -3927,7 +4007,12 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self._ensure_knowledge_processed()
if not skip_retrieval and self.knowledge:
search_results = self.knowledge.search(prompt, agent_id=self.agent_id)
# Knowledge.search is synchronous (Mem0 embedding + vector-store
# I/O). Offload to a thread so it never blocks the event loop for
# other coroutines sharing the loop.
search_results = await asyncio.to_thread(
self.knowledge.search, prompt, agent_id=self.agent_id
)
if search_results:
if isinstance(search_results, dict) and 'results' in search_results:
knowledge_content = "\n".join([result['memory'] for result in search_results['results']])
@@ -4710,6 +4795,19 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
from .tools_placement import ensure_tools_placed
ensure_tools_placed(self)
# Input-side guardrail runs BEFORE begin_durable_run so a rejected prompt
# never opens (and therefore never finalizes) a durable run -- exact
# parity with chat()/achat(), which validate input before begin_durable_run
# and return early. Doing it here (not in _start_stream_impl) keeps a
# blocked stream from being recorded as a "succeeded" durable run.
if hasattr(self, '_validate_input_with_guardrail'):
_in_ok, _in_prompt, _in_err = self._validate_input_with_guardrail(prompt)
if not _in_ok:
logging.warning(f"Agent {getattr(self, 'name', '')}: input blocked by guardrail: {_in_err}")
yield f"[Input blocked by guardrail: {_in_err}]"
return
prompt = _in_prompt
durable_context = None
durable_token = None
try:
@@ -4751,6 +4849,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
"applied to streamed responses (iter_stream / stream=True). "
"Use chat() for guardrail-validated output."
)
# Input-side guardrail validation is performed by _start_stream (before
# begin_durable_run), so the prompt reaching this generator is already
# validated/transformed. Kept out of this impl so a blocked prompt never
# opens a durable run that would then finalize as "succeeded".
try:
# Reset the final display flag for each new conversation
self._final_display_shown = False
@@ -4815,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(
@@ -4823,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,
@@ -4904,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:
@@ -5170,6 +5295,17 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self._append_to_chat_history(
{"role": "assistant", "content": followup_text}
)
else:
# Tools ran and the model then said nothing. Simply
# returning here is indistinguishable from a model
# that had nothing to say, and that is exactly how
# this reached users: the generator ended having
# yielded not one character, and the caller could
# only report "no output" for a turn whose tools
# had all succeeded. Fail loudly instead.
raise RuntimeError(
"the model called {} tool(s) and then returned "
"no answer".format(len(tool_calls_data)))
else:
# Add complete response to chat history (text-only response)
if response_text:
@@ -5714,6 +5850,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
async def _achat_completion_with_retry_core(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
"""Async retry/backoff core for chat completion (middleware-agnostic)."""
# Reset the agent-level finish-reason classification at the start of each
# async OpenAI-native turn, mirroring the sync ``_chat_completion`` reset,
# so a provider block/refusal recorded on a previous run (see
# ``_extract_llm_response_content``) never leaks into this one. The LiteLLM
# path resets its own backend flag independently.
self._last_stop_reason = "completed"
retry_config = getattr(self, '_retry_config', None)
if not retry_config:
return await self._execute_unified_achat_completion(
@@ -297,7 +297,33 @@ class ExecutionMixin:
raise
except Exception as exc: # noqa: BLE001 - normalised into outcome
return RunOutcome.from_exception(exc)
return RunOutcome.completed(output=str(result) if result is not None else None)
return self._outcome_for_result(result)
def _outcome_for_result(self, result):
"""Map a normally-returned run result into a canonical RunOutcome.
A run that returned without raising is ``completed`` *unless* the core
recorded a provider block/refusal/truncation on ``last_stop_reason`` from
the LLM ``finish_reason``/refusal signal — in which case the specific,
actionable terminal reason (``content_filtered | refused |
length_truncated``) is surfaced instead of a silent empty ``completed``.
Any other stop reason (``completed``/``max_steps``/unknown) preserves the
existing ``completed`` semantics, so behaviour is unchanged on success.
"""
from .run_outcome import RunOutcome, PROVIDER_BLOCK_REASONS
output = str(result) if result is not None else None
# Read the recorded classification without letting a lookup problem mask a
# provider block as a successful ``completed``. Only an expected
# missing-state lookup (``AttributeError``) is treated as "no reason
# recorded"; anything else propagates rather than silently succeeding.
reason = None
try:
reason = getattr(self, "last_stop_reason", None)
except AttributeError:
reason = None
if reason in PROVIDER_BLOCK_REASONS:
return RunOutcome(reason=reason, output=output)
return RunOutcome.completed(output=output)
def run(self, prompt: str, **kwargs: Any) -> Optional[str]:
"""Execute agent silently and return structured result.
@@ -383,7 +409,7 @@ class ExecutionMixin:
result = executor()
except BaseException as exc: # noqa: BLE001 - normalised into outcome
return RunOutcome.from_exception(exc)
return RunOutcome.completed(output=str(result) if result is not None else None)
return self._outcome_for_result(result)
def _delegate_to_backend(self, prompt: str, **kwargs) -> Optional[str]:
"""Delegate execution to external managed backend (e.g., ManagedAgentIntegration).
@@ -776,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.
@@ -1676,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
@@ -1694,11 +1751,51 @@ 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"}
# Circuit breaker (pre-execution) — parity with the sync path
# (_execute_tool_with_circuit_breaker_impl). Without this the async
# tool path never engages the per-agent breaker, so a persistently
# failing external tool is hammered indefinitely via achat()/astart()
# and the model never receives the ``circuit_open`` signal the async
# retry loop already checks for. The helpers are pure/sync.
breaker_precheck = getattr(self, '_circuit_breaker_precheck', None)
if breaker_precheck is not None:
open_result = breaker_precheck(function_name)
if open_result is not None:
return open_result
# Loop guard (pre-execution) — parity with the sync path in
# tool_execution.py. The async path previously had no loop-guard or
# doom-loop protection, so a repeatedly-failing tool could be hammered
@@ -1762,22 +1859,128 @@ 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))
)
# Circuit-breaker parity with the sync path (tool_execution.py).
# The async tool path previously had no breaker, so a repeatedly
# failing tool could be hammered every turn via achat()/async
# workflows. Wrap the invocation through the same per-agent/per-tool
# breaker (CircuitBreaker.acall is event-loop safe) so repeated
# failures OPEN the circuit and short-circuit further calls. A
# CircuitBreakerException is surfaced as an error dict carrying
# circuit_open=True, which the retry loop in
# _execute_tool_async_with_retry already treats as terminal.
breaker = None
# Sentinel exception type so the `except` clause below is always a
# valid type even when the circuit_breaker module is unavailable
# (a never-raised local class never matches a real exception).
class _CircuitBreakerException(Exception):
pass
try:
from ..tools.circuit_breaker import (
get_circuit_breaker,
CircuitBreakerConfig,
CircuitBreakerException as _CircuitBreakerException,
)
breaker_name = f"tool_{id(self)}_{function_name}"
breaker = get_circuit_breaker(
breaker_name,
CircuitBreakerConfig(
failure_threshold=5,
recovery_timeout=60.0,
timeout=30.0,
graceful_degradation=True,
),
)
if hasattr(self, "_register_breaker_finalizer"):
self._register_breaker_finalizer(breaker_name)
except ImportError:
# Circuit breaker not available - fall back to direct invocation.
logging.debug("Circuit breaker not available on async path, executing directly")
# Sentinel used to register an error-dict result as a breaker
# failure without losing the dict, mirroring the sync path's
# _ToolFailure wrapper (tool_execution.py). Approval/permission/
# policy/guardrail denials are NOT treated as breaker failures.
class _ToolFailure(Exception):
def __init__(self, error_dict):
self.error_dict = error_dict
super().__init__(error_dict.get("error", "Tool execution failed"))
async def _invoke_for_breaker():
r = await _invoke()
if isinstance(r, dict) and r.get("error") and \
not r.get("approval_denied") and \
not r.get("permission_denied") and \
not r.get("approval_error") and \
not r.get("policy_denied") and \
not r.get("guardrail_denied"):
raise _ToolFailure(r)
return r
async def _invoke_guarded():
if breaker is None:
return await _invoke()
try:
return await breaker.acall(_invoke_for_breaker)
except _ToolFailure as tf:
# Failure was counted by the breaker; return the original dict.
return tf.error_dict
def _circuit_open_result():
# Record the rejection as a failure so repeated open-circuit
# rejections still feed the loop guard, then surface an error
# dict carrying circuit_open=True — the retry loop in
# _execute_tool_async_with_retry treats it as terminal.
if loop_guard is not None:
loop_guard.record(function_name, arguments, False, result=None)
return {
"error": f"Tool '{function_name}' circuit breaker open - too many recent failures",
"circuit_open": True,
"agent_name": getattr(self, "name", None),
"session_id": getattr(self, "_session_id", None),
"remediation": "Wait for recovery_timeout (60s) or investigate recent tool failures.",
}
# Apply the per-agent tool timeout (ToolConfig.timeout) so the async
# path matches the sync path in tool_execution.py. asyncio.wait_for
# cannot kill a stuck sync tool running in the executor (same caveat
@@ -1786,7 +1989,10 @@ Write the complete compiled report:"""
tool_timeout = getattr(self, '_tool_timeout', None)
if tool_timeout and tool_timeout > 0:
try:
result = await asyncio.wait_for(_invoke(), timeout=tool_timeout)
result = await asyncio.wait_for(_invoke_guarded(), timeout=tool_timeout)
except _CircuitBreakerException as cbe:
logging.warning(f"Tool '{function_name}' circuit breaker open: {cbe}")
return _circuit_open_result()
except asyncio.TimeoutError:
logging.warning(f"Tool {function_name} timed out after {tool_timeout}s")
# Mark as non-retryable: asyncio.wait_for cannot cancel a sync
@@ -1803,7 +2009,30 @@ Write the complete compiled report:"""
"_praison_retryable": False,
}
else:
result = await _invoke()
try:
result = await _invoke_guarded()
except _CircuitBreakerException as cbe:
logging.warning(f"Tool '{function_name}' circuit breaker open: {cbe}")
return _circuit_open_result()
# Circuit breaker (post-execution) — record the outcome so
# repeated failures open the breaker, mirroring the sync path's
# breaker.call. Approval/permission/policy/guardrail denials are
# NOT counted as tool failures (same exclusions the sync wrapper
# applies in _execute_tool_with_circuit_breaker_impl), so a gated
# tool never trips the breaker.
breaker_record = getattr(self, '_circuit_breaker_record', None)
if breaker_record is not None:
is_breaker_failure = (
isinstance(result, dict)
and result.get("error")
and not result.get("approval_denied")
and not result.get("permission_denied")
and not result.get("approval_error")
and not result.get("policy_denied")
and not result.get("guardrail_denied")
)
breaker_record(function_name, not is_breaker_failure)
# Loop guard (post-execution) — record the outcome and surface a
# block/halt decision back to the model on this same turn, mirroring
@@ -1834,6 +2063,17 @@ Write the complete compiled report:"""
except Exception as e:
logging.error(f"Error executing {function_name}: {str(e)}", exc_info=True)
# Circuit breaker (failure on raised exception) — a raised tool
# exception is a failure just like an error-dict result, and the
# sync path's ``breaker.call`` counts it. Record it here so the
# breaker still opens after repeated raises; otherwise the
# post-invoke record block above is skipped and raised failures
# never trip the breaker. Approval/permission/policy/guardrail
# denials surface as error dicts (handled above), not raises, so
# this path only ever sees genuine tool failures.
breaker_record = getattr(self, '_circuit_breaker_record', None)
if breaker_record is not None:
breaker_record(function_name, False)
# Record the failed invocation so repeated identical failures
# accumulate toward the loop-guard BLOCK/HALT thresholds — a raised
# tool exception is a failure just like an error-dict result on the
@@ -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)."""
@@ -18,18 +18,36 @@ except ImportError: # pragma: no cover - py<3.8 fallback
if Literal is not None:
TerminalReason = Literal[
"completed", "hard_timeout", "cancelled", "aborted", "failed"
"completed",
"hard_timeout",
"cancelled",
"aborted",
"failed",
"content_filtered",
"refused",
"length_truncated",
]
else: # pragma: no cover
TerminalReason = str # type: ignore
# Provider-side terminal reasons derived from the LLM ``finish_reason``/refusal
# signal. Additive and backward-compatible: absent/unknown finish reasons keep
# the existing ``completed|failed|...`` semantics unchanged.
PROVIDER_BLOCK_REASONS = ("content_filtered", "refused", "length_truncated")
# Precedence: higher wins and is sticky (a hard timeout is not downgraded).
# A specific provider block/refusal/truncation outranks a generic ``failed`` so
# the actionable reason is not masked, but stays below cancellation/timeout,
# which are host-level lifecycle signals.
_REASON_PRECEDENCE = {
"completed": 0,
"failed": 1,
"aborted": 2,
"cancelled": 3,
"hard_timeout": 4,
"content_filtered": 2,
"refused": 2,
"length_truncated": 2,
"aborted": 3,
"cancelled": 4,
"hard_timeout": 5,
}
@@ -93,3 +111,27 @@ class RunOutcome:
def _name_matches(exc: BaseException, needles: tuple) -> bool:
name = type(exc).__name__.lower()
return any(n in name for n in needles)
def classify_finish_reason(finish_reason, refusal=None):
"""Map a provider ``finish_reason``/refusal signal to a terminal reason.
Returns one of ``content_filtered | refused | length_truncated`` when the
provider blocked/refused/truncated the turn, or ``None`` for a normal stop
(``None``/``"stop"``) or any unrecognised value — so unknown finish reasons
behave exactly as today. Additive and zero-cost on the success path.
"""
if refusal:
return "refused"
if not finish_reason:
return None
fr = str(finish_reason).lower()
if fr in ("stop", "tool_calls", "function_call"):
return None
if "content_filter" in fr or fr == "content_filtered":
return "content_filtered"
if "refus" in fr:
return "refused"
if fr == "length" or "max_tokens" in fr or "truncat" in fr:
return "length_truncated"
return None
@@ -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,
),
)
@@ -883,15 +911,36 @@ class ToolExecutionMixin:
# the _outer_timeout clause above already does.
and not self._tool_declares_not_idempotent(function_name))
)
# A tool that *raised* is flattened into an error dict
# tagged with ``_praison_retryable`` (see
# _execute_tool_impl). A raised exception is a
# framework-level tool FAILURE — it must surface as a
# ToolExecutionError so the run is finalized failed and
# a genuine RuntimeError/SMTP-down/rate-limit-text tool
# is never silently downgraded to a success (parity with
# the base behaviour and test_tool_failure_not_relabelled).
# ``is_retryable`` above decides only whether the outer
# loop retries, not whether it raises.
raised_exception = "_praison_retryable" in result
# Strip the private control-plane tag before it can reach
# the model or be re-surfaced as the tool's payload.
result.pop("_praison_retryable", None)
raise ToolExecutionError(
result.get("error", f"Tool '{function_name}' failed"),
tool_name=function_name,
agent_id=self.name,
is_retryable=is_retryable,
)
if is_retryable or raised_exception:
raise ToolExecutionError(
result.get("error", f"Tool '{function_name}' failed"),
tool_name=function_name,
agent_id=self.name,
is_retryable=is_retryable,
)
# A plain tool-authored {"error": ...} with none of the
# transient/denial markers and no raised-exception tag is
# the tool's own documented way of *returning* a
# recoverable failure (see tools/shell_tools.py and the
# other bundled tools). It is the tool's answer, not a
# framework-level crash — hand it back as a normal tool
# result so the LLM can see it and self-correct, instead
# of aborting the whole run.
break
else:
# Success path - return the result
break
@@ -1626,6 +1675,12 @@ class ToolExecutionMixin:
or (trust_level == "external") # External tools need approval
)
if needs_approval:
# Attach a rendered unified diff for file-mutating tools so the
# reviewer approves the actual change rather than a truncated
# argument dump. Non-edit tools get no diff (``None``) and keep
# the existing argument summary.
from ..approval.utils import build_diff_preview
diff_preview = build_diff_preview(tool_name, tool_args)
request = ApprovalRequest(
tool_name=tool_name,
arguments=tool_args,
@@ -1634,6 +1689,7 @@ class ToolExecutionMixin:
or DEFAULT_DANGEROUS_TOOLS.get(tool_name, "medium")
),
agent_name=getattr(self, 'name', None),
context={"diff": diff_preview} if diff_preview else {},
)
if is_async:
@@ -1699,15 +1755,32 @@ class ToolExecutionMixin:
# registry state (which leaked onto other agents when this agent had
# no stable name), pass the intent per-call via ``force`` so the
# registry prompts for *this* call only without side effects.
# Skill auto-approval grants are keyed by this agent's unique
# per-instance scope id (not the display name, which defaults to
# "Agent" for every unnamed agent), so a skill grant on one agent
# can never unlock the tool for an unrelated agent in the process.
auto_approve_scope = getattr(self, '_approval_scope_id', None)
# Pass the per-instance scope id as ``scope_id`` too, so the
# in-memory "this session" grant store is keyed per Agent instance
# rather than by the display name (every unnamed Agent() defaults to
# "Agent"). Without this, one worker's human-granted "[s] this
# session" approval silently unlocks the same tool for a distinct
# same-named worker. ``self.name`` remains the ``agent_name`` for the
# name-keyed lookups (per-agent backend, risk level, ask rules,
# durable "always" persistence).
if is_async:
return get_approval_registry().approve_async(
getattr(self, 'name', None), tool_name, tool_args,
force=manager_forces_approval,
auto_approve_scope=auto_approve_scope,
scope_id=auto_approve_scope,
)
else:
return get_approval_registry().approve_sync(
getattr(self, 'name', None), tool_name, tool_args,
force=manager_forces_approval,
auto_approve_scope=auto_approve_scope,
scope_id=auto_approve_scope,
)
def _doom_loop_approved(self, function_name, arguments, verdict) -> bool:
@@ -1765,11 +1838,17 @@ class ToolExecutionMixin:
function_name, arguments
),
}
# Key the "this session" grant by the per-instance scope id so a
# doom-loop continue granted on one worker cannot silently authorise
# a same-named sibling worker, matching the per-instance scoping used
# for regular tool approvals above. ``self.name`` stays as agent_name
# for the name-keyed lookups.
decision = registry.approve_sync(
getattr(self, "name", None),
DOOM_LOOP_TARGET,
request_args,
force=True,
scope_id=getattr(self, "_approval_scope_id", None),
)
return bool(getattr(decision, "approved", False))
except Exception as e: # noqa: BLE001 — fail closed to the block path
@@ -2076,7 +2155,8 @@ class ToolExecutionMixin:
from ..approval import get_approval_registry
get_approval_registry().mark_approved(
function_name, arguments, agent_name=getattr(self, "name", None)
function_name, arguments, agent_name=getattr(self, "name", None),
scope_id=getattr(self, "_approval_scope_id", None),
)
return None, arguments
@@ -2121,7 +2201,8 @@ class ToolExecutionMixin:
from ..approval import get_approval_registry
get_approval_registry().mark_approved(
function_name, arguments, agent_name=getattr(self, "name", None)
function_name, arguments, agent_name=getattr(self, "name", None),
scope_id=getattr(self, "_approval_scope_id", None),
)
return None, arguments
@@ -2266,6 +2347,83 @@ class ToolExecutionMixin:
except Exception:
pass
def _get_tool_circuit_breaker(self, function_name):
"""Return this agent's circuit breaker for *function_name*, or None.
Shared between the sync tool path (_execute_tool_with_circuit_breaker_impl)
and the async tool path (_execute_tool_async_impl) so both engage the
same per-agent breaker. Uses the identical instance-scoped key so one
agent's failing tool cannot trip another agent's same-named tool.
Returns None when the circuit_breaker module is unavailable.
"""
try:
from ..tools.circuit_breaker import get_circuit_breaker, CircuitBreakerConfig
except ImportError:
return None
breaker_name = f"tool_{id(self)}_{function_name}"
config = CircuitBreakerConfig(
failure_threshold=5,
recovery_timeout=60.0,
timeout=30.0,
graceful_degradation=True,
)
breaker = get_circuit_breaker(breaker_name, config)
self._register_breaker_finalizer(breaker_name)
return breaker
def _circuit_breaker_open_result(self, function_name):
"""Return the standard ``circuit_open`` error dict for *function_name*."""
return {
"error": f"Tool '{function_name}' circuit breaker open - too many recent failures",
"circuit_open": True,
"agent_name": getattr(self, "name", None),
"session_id": getattr(self, "_session_id", None),
"remediation": "Wait for recovery_timeout (60s) or investigate recent tool failures.",
}
def _circuit_breaker_precheck(self, function_name):
"""Short-circuit an OPEN breaker before running the tool body.
Mirrors the OPEN-state rejection logic of ``CircuitBreaker.call`` so the
async tool path gets the same protection as the sync path without needing
to funnel its async ``_invoke`` through the sync ``breaker.call``.
Returns a ``circuit_open`` error dict when the call must be rejected, or
None when the tool is allowed to run (and mutates the breaker into
HALF_OPEN when the recovery timeout has elapsed, exactly like ``call``).
"""
breaker = self._get_tool_circuit_breaker(function_name)
if breaker is None:
return None
try:
from ..tools.circuit_breaker import CircuitState
except ImportError:
return None
with breaker._lock:
breaker._stats.total_requests += 1
if breaker._stats.state == CircuitState.OPEN:
if not breaker._should_attempt_reset():
breaker._stats.rejected_requests += 1
return self._circuit_breaker_open_result(function_name)
breaker._stats.state = CircuitState.HALF_OPEN
breaker._stats.success_count = 0
return None
def _circuit_breaker_record(self, function_name, is_success):
"""Record a tool outcome against this agent's breaker.
Used by the async path after ``_invoke`` so repeated failures open the
breaker just as the sync path's ``breaker.call`` does. Treated as a
no-op when the breaker is unavailable.
"""
breaker = self._get_tool_circuit_breaker(function_name)
if breaker is None:
return
if is_success:
breaker._on_success()
else:
breaker._on_failure()
def _execute_tool_with_circuit_breaker_impl(self, function_name, arguments):
"""Execute tool with circuit breaker protection (internal implementation).
@@ -2427,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
@@ -2540,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
@@ -2584,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):
@@ -2591,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'):
@@ -2603,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'):
@@ -2615,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):
@@ -2623,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}")
@@ -2774,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
@@ -2852,12 +3035,15 @@ class ToolExecutionMixin:
from ..approval.protocols import ApprovalRequest
from ..approval.registry import DEFAULT_DANGEROUS_TOOLS
from ..approval.utils import build_diff_preview
diff_preview = build_diff_preview(function_name, arguments)
request = ApprovalRequest(
tool_name=function_name,
arguments=arguments,
risk_level=DEFAULT_DANGEROUS_TOOLS.get(function_name, "medium"),
agent_name=getattr(self, 'name', None),
context={"diff": diff_preview} if diff_preview else {},
)
tracking_id = str(uuid.uuid4())[:8]
@@ -93,10 +93,25 @@ class VideoAgent:
Supported Providers:
- OpenAI: openai/sora-2, openai/sora-2-pro
- Azure: azure/sora-2, azure/sora-2-pro
- Gemini: gemini/veo-3.0-generate-preview, gemini/veo-3.1-*
- Vertex AI: vertex_ai/veo-3.0-*, vertex_ai/veo-3.1-*
- Gemini Veo 3.1 (paid API, uses GOOGLE_API_KEY / GEMINI_API_KEY):
- gemini/veo-3.1-generate-preview # Standard / cinematic quality
- gemini/veo-3.1-fast-generate-preview # Fast
- gemini/veo-3.1-lite-generate-preview # Lite (cheapest iteration)
- Vertex AI Veo 3.1 (uses GOOGLE_APPLICATION_CREDENTIALS or VERTEXAI_PROJECT/VERTEXAI_LOCATION):
- vertex_ai/veo-3.1-generate-001 # GA Standard
- vertex_ai/veo-3.1-fast-generate-001 # GA Fast
- vertex_ai/veo-3.1-lite-generate-001 # Lite
- RunwayML: runwayml/gen4_turbo (requires input_reference)
Deprecated (do NOT recommend; kept working only if LiteLLM still routes them):
- gemini/veo-2.0-generate-001 (Gemini API shutdown 30 Jun 2026)
- gemini/veo-3.0-generate-preview, gemini/veo-3.0-fast-generate-preview
- vertex_ai/veo-3.0-* (migrate to the veo-3.1 IDs above)
Note on pricing: Gemini/Vertex Veo bill in USD per generated second via the paid
API (charged only on success) and are a different meter from Flow UI credits — a
Flow subscription cannot pay for API generations. See praison.ai/docs/video/gemini.
Example:
```python
from praisonaiagents import VideoAgent
@@ -114,7 +129,7 @@ class VideoAgent:
# With config
agent = VideoAgent(
llm="gemini/veo-3.0-generate-preview",
llm="gemini/veo-3.1-generate-preview",
video=VideoConfig(seconds="8", size="1280x720")
)
```
@@ -146,7 +161,7 @@ class VideoAgent:
Args:
name: Agent name for identification
instructions: Optional instructions (unused for video generation)
llm: Model name string (e.g., "openai/sora-2", "gemini/veo-3.0-generate-preview")
llm: Model name string (e.g., "openai/sora-2", "gemini/veo-3.1-generate-preview")
model: Alias for llm= parameter
base_url: Custom API endpoint URL
api_key: API key for the provider
@@ -300,8 +315,12 @@ class VideoAgent:
prompt: Text description of the desired video
seconds: Video duration (e.g., "8", "16"). Defaults to config value.
size: Video dimensions (e.g., "720x1280"). Defaults to config value.
input_reference: Reference image for image-to-video generation
**kwargs: Additional provider-specific parameters
input_reference: Reference image for image-to-video (first-frame) generation
**kwargs: Additional provider-specific parameters, forwarded to LiteLLM.
For Veo 3.1 interpolation/reference features, pass them here (no
dedicated params are added to keep the surface lightweight):
- last_frame=<image> # first + last frame (Veo 3.1 only)
- reference_images=[...] # up to 3 reference images
Returns:
VideoObject with id, status, and metadata
@@ -315,6 +334,14 @@ class VideoAgent:
)
print(f"Video ID: {video.id}")
print(f"Status: {video.status}")
# Veo 3.1 first + last frame interpolation (via kwargs passthrough)
veo = VideoAgent(llm="gemini/veo-3.1-generate-preview")
veo.generate(
prompt="Sunrise timelapse",
input_reference=start_image, # first frame
last_frame=end_image, # last frame (Veo 3.1)
)
```
"""
# Merge config with kwargs
@@ -1338,6 +1338,28 @@ class AgentTeam(SpawnAnnounceProtocol):
logger.warning(f"Task {task_id}: Guardrail processing error (retry {task.retry_count}/{task.max_retries}): {e}")
return task_output, True # Signal retry needed
async def _aapply_task_guardrail(self, task, task_id, task_output):
"""Async wrapper for _apply_task_guardrail.
A string/LLMGuardrail guardrail fires a *blocking* LLM call inside
_apply_task_guardrail. Offload it to a thread so it does not stall the
event loop (and every other task running concurrently under
asyncio.gather in arun_all_tasks/astart), mirroring the executor-offload
pattern used for synchronous memory calls in async_memory_mixin.
"""
loop = asyncio.get_event_loop()
# Preserve contextvars (trace emission, session context) across the
# executor thread so a custom task guardrail sees the same contextual
# state as the synchronous path, matching every other run_in_executor
# call site in this module.
from ..trace.context_events import copy_context_to_callable
return await loop.run_in_executor(
None,
copy_context_to_callable(
lambda: self._apply_task_guardrail(task, task_id, task_output)
),
)
def _run_task_start_hook(self, task, task_id):
"""Run the on_task_start hook and propagate global variables to the task.
@@ -1417,8 +1439,9 @@ class AgentTeam(SpawnAnnounceProtocol):
if task.status in ["not started", "in progress"]:
task_output = await self.aexecute_task(task_id)
if task_output and self.completion_checker(task, task_output.raw):
# Apply guardrail validation using shared helper
task_output, should_retry = self._apply_task_guardrail(task, task_id, task_output)
# Apply guardrail validation using shared helper (offloaded to
# a thread so a blocking LLM guardrail does not stall the loop)
task_output, should_retry = await self._aapply_task_guardrail(task, task_id, task_output)
if should_retry:
retries += 1
continue
@@ -2587,21 +2610,110 @@ class AgentTeam(SpawnAnnounceProtocol):
return False
def _own_agent_names(self) -> set:
"""Names of this instance's own agents, for token-report scoping."""
return {
getattr(agent, "name", None)
for agent in (self.agents or [])
if getattr(agent, "name", None)
}
def _scoped_recent_interactions(self, own_names: set) -> List[Dict[str, Any]]:
"""Return this instance's own recorded interactions, most-recent last.
The process-wide collector keeps a bounded window of every agent's
interactions; filter it to this instance's agent names so per-model
totals, interaction counts, and the detailed report never leak another
concurrent instance's records.
"""
try:
interactions = get_token_collector().get_recent_interactions(
limit=get_token_collector()._max_recent
)
except Exception:
interactions = get_token_collector().get_recent_interactions(limit=100)
return [i for i in interactions if i.get("agent") in own_names]
def _scoped_token_summary(self) -> Dict[str, Any]:
"""Return the global token summary filtered to this instance's agents.
The process-wide TokenCollector aggregates every agent in the process,
so two concurrent PraisonAIAgents instances would otherwise read each
other's totals. Scope every reported field — ``by_agent``, ``by_model``,
``total_interactions`` and the totals — to this instance's own agent
names so per-instance/per-tenant cost accounting reports only its own
spend. ``by_agent`` is taken from the collector's aggregate breakdown;
``by_model`` and ``total_interactions`` are re-derived from this
instance's own interaction records (the aggregate cannot be split by
model per agent). Falls back to the unfiltered summary when this
instance has no named agents to scope by.
"""
summary = get_token_collector().get_session_summary()
own_names = self._own_agent_names()
by_agent = summary.get("by_agent", {})
# No named agents to scope by (or nothing tracked yet): fall back to the
# unfiltered summary rather than silently reporting zeros.
if not own_names or not by_agent:
return summary
scoped_by_agent = {
name: metrics for name, metrics in by_agent.items() if name in own_names
}
totals = {
"input_tokens": 0, "output_tokens": 0, "cached_tokens": 0,
"reasoning_tokens": 0, "audio_input_tokens": 0, "audio_output_tokens": 0,
"total_tokens": 0,
}
for metrics in scoped_by_agent.values():
for key, value in metrics.items():
if isinstance(value, (int, float)):
totals[key] = totals.get(key, 0) + value
# Re-derive per-model totals and the interaction count from this
# instance's own records so they are not copied wholesale from the
# process-global summary (which mixes in other instances' models and
# counts). The collector's ``by_agent`` aggregate cannot be split by
# model, so the per-interaction window is the correct source.
own_interactions = self._scoped_recent_interactions(own_names)
by_model: Dict[str, Dict[str, int]] = {}
for interaction in own_interactions:
model = interaction.get("model")
metrics = interaction.get("metrics") or {}
if not model:
continue
bucket = by_model.setdefault(model, {})
for key, value in metrics.items():
if isinstance(value, (int, float)):
bucket[key] = bucket.get(key, 0) + value
return {
"total_interactions": len(own_interactions),
"total_tokens": totals.get("total_tokens", 0),
"total_metrics": totals,
"by_model": by_model,
"by_agent": scoped_by_agent,
}
def get_token_usage_summary(self) -> Dict[str, Any]:
"""Get a summary of token usage across all agents and tasks."""
"""Get a summary of token usage across this team's agents and tasks."""
if not get_token_collector:
return {"error": "Token tracking not available"}
return get_token_collector().get_session_summary()
return self._scoped_token_summary()
def get_detailed_token_report(self) -> Dict[str, Any]:
"""Get a detailed token usage report."""
if not get_token_collector:
return {"error": "Token tracking not available"}
collector = get_token_collector()
summary = collector.get_session_summary()
recent = collector.get_recent_interactions(limit=20)
summary = self._scoped_token_summary()
own_names = self._own_agent_names()
if own_names:
# Only surface this instance's own interactions so the detailed
# report never exposes another concurrent instance's records.
recent = self._scoped_recent_interactions(own_names)[-20:]
else:
recent = get_token_collector().get_recent_interactions(limit=20)
# Calculate cost estimates (example rates)
cost_per_1k_input = 0.0005 # $0.0005 per 1K input tokens
@@ -2629,7 +2741,7 @@ class AgentTeam(SpawnAnnounceProtocol):
print("Token tracking not available")
return
summary = get_token_collector().get_session_summary()
summary = self._scoped_token_summary()
print("\n" + "="*50)
print("TOKEN USAGE SUMMARY")
@@ -3424,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
@@ -3628,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
@@ -101,15 +101,17 @@ def mark_approved(
tool_name: str,
arguments: Optional[Dict] = None,
agent_name: Optional[str] = None,
scope_id: Optional[str] = None,
) -> None:
get_approval_registry().mark_approved(tool_name, arguments, agent_name)
get_approval_registry().mark_approved(tool_name, arguments, agent_name, scope_id)
def is_already_approved(
tool_name: str,
arguments: Optional[Dict] = None,
agent_name: Optional[str] = None,
scope_id: Optional[str] = None,
) -> bool:
return get_approval_registry().is_already_approved(tool_name, arguments, agent_name)
return get_approval_registry().is_already_approved(tool_name, arguments, agent_name, scope_id)
def is_yaml_approved(tool_name: str) -> bool:
return get_approval_registry().is_yaml_approved(tool_name)
@@ -80,6 +80,35 @@ class AutoApproveBackend:
class ConsoleBackend:
"""Interactive Rich terminal prompt. Default for CLI usage."""
@staticmethod
def _render_diff_markup(diff: str, max_lines: int = 40) -> str:
"""Return Rich-markup for a unified *diff* with coloured +/- hunks.
Escapes any literal ``[`` in the diff so raw file content can never be
interpreted as Rich markup. Added lines are green, removed lines red,
hunk headers cyan and context lines dimmed. Bounded to ``max_lines``.
"""
from rich.markup import escape
lines = diff.splitlines()
rendered = []
for i, line in enumerate(lines):
if i >= max_lines:
rendered.append("[dim]... (diff truncated)[/dim]")
break
safe = escape(line)
if line.startswith("+++") or line.startswith("---"):
rendered.append(f"[bold]{safe}[/bold]")
elif line.startswith("@@"):
rendered.append(f"[cyan]{safe}[/cyan]")
elif line.startswith("+"):
rendered.append(f"[green]{safe}[/green]")
elif line.startswith("-"):
rendered.append(f"[red]{safe}[/red]")
else:
rendered.append(f"[dim]{safe}[/dim]")
return "\n".join(rendered) + "\n"
def _prompt_user(self, request: ApprovalRequest):
"""Show Rich panel and ask once/session/always/no/deny-with-guidance.
@@ -108,12 +137,22 @@ class ConsoleBackend:
tool_info += f"[bold]Risk Level:[/] [{risk_color}]{request.risk_level.upper()}[/{risk_color}]\n"
if request.agent_name:
tool_info += f"[bold]Agent:[/] {request.agent_name}\n"
tool_info += "[bold]Arguments:[/]\n"
for key, value in request.arguments.items():
str_value = str(value)
if len(str_value) > 100:
str_value = str_value[:97] + "..."
tool_info += f" {key}: {str_value}\n"
# For file-mutating tools a rendered unified diff is attached to the
# request context so the reviewer sees the actual change (path plus
# +/- hunks) instead of a truncated argument dump. Fall back to the
# argument summary when no diff is present (non-edit tools).
diff = (request.context or {}).get("diff")
if diff:
tool_info += "[bold]Diff:[/]\n"
tool_info += self._render_diff_markup(diff)
else:
tool_info += "[bold]Arguments:[/]\n"
for key, value in request.arguments.items():
str_value = str(value)
if len(str_value) > 100:
str_value = str_value[:97] + "..."
tool_info += f" {key}: {str_value}\n"
console.print(Panel(
tool_info.strip(),
@@ -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
@@ -245,23 +245,34 @@ class ApprovalRegistry:
@staticmethod
def _approval_cache_key(
tool_name: str, arguments: Dict, agent_name: Optional[str] = None
tool_name: str,
arguments: Dict,
agent_name: Optional[str] = None,
scope_id: Optional[str] = None,
) -> str:
# Scope the key to the requesting agent so one agent's approval never
# silently pre-authorizes an identical call from a different, stricter
# agent in the same context. ``*`` is the sentinel for calls made
# outside any Agent (e.g. bare module-level tool calls).
# agent in the same context. Prefer the per-instance ``scope_id`` (e.g.
# ``Agent._approval_scope_id``) when supplied: the display ``agent_name``
# defaults to ``"Agent"`` for every unnamed agent, so keying the
# context cache by name alone lets two same-named instances sharing an
# execution context pre-authorize each other before the per-instance
# session lookup runs. Falls back to ``agent_name`` for backward compat.
# ``*`` is the sentinel for calls made outside any Agent (e.g. bare
# module-level tool calls).
from .utils import hash_tool_args
return f"{agent_name or '*'}:{tool_name}:{hash_tool_args(arguments)}"
scope = scope_id or agent_name or '*'
return f"{scope}:{tool_name}:{hash_tool_args(arguments)}"
def mark_approved(
self,
tool_name: str,
arguments: Optional[Dict] = None,
agent_name: Optional[str] = None,
scope_id: Optional[str] = None,
) -> None:
approved = self._approved_context.get(set())
approved.add(self._approval_cache_key(tool_name, arguments or {}, agent_name))
approved.add(self._approval_cache_key(tool_name, arguments or {}, agent_name, scope_id))
self._approved_context.set(approved)
def is_already_approved(
@@ -269,21 +280,32 @@ class ApprovalRegistry:
tool_name: str,
arguments: Optional[Dict] = None,
agent_name: Optional[str] = None,
scope_id: Optional[str] = None,
) -> bool:
# Honour an explicit mark_approved() from the agent approval path even
# for critical tools (e.g. execute_command after AutoApproveBackend).
if self._approval_cache_key(tool_name, arguments or {}, agent_name) in self._approved_context.get(set()):
if self._approval_cache_key(tool_name, arguments or {}, agent_name, scope_id) in self._approved_context.get(set()):
return True
return False
def _is_session_scoped(
self, agent_name: Optional[str], tool_name: str, arguments: Optional[Dict]
self,
agent_name: Optional[str],
tool_name: str,
arguments: Optional[Dict],
scope_id: Optional[str] = None,
) -> bool:
"""Return True if a "this session" grant covers this call for the run.
Checks the in-memory session store by the reusable permission target so
a single ``session`` approval covers matching calls (e.g. the same
``bash:git status *`` prefix) without persisting anything to disk.
``scope_id`` is a per-Agent-instance key (e.g. ``Agent._approval_scope_id``).
When provided it is used as the store key instead of the display name so
two same-named agents (every unnamed ``Agent()`` defaults to ``"Agent"``)
can't inherit each other's human-granted session approval. Falls back to
``agent_name`` for backward compatibility when no id is supplied.
"""
if not self._session_scoped_targets:
return False
@@ -293,7 +315,8 @@ class ApprovalRegistry:
target = build_permission_target(tool_name, arguments)
except Exception: # noqa: BLE001 — never block on target derivation
return False
return (agent_name, target) in self._session_scoped_targets
key = scope_id if scope_id is not None else agent_name
return (key, target) in self._session_scoped_targets
def _persist_scoped_decision(
self,
@@ -301,6 +324,7 @@ class ApprovalRegistry:
tool_name: str,
arguments: Optional[Dict],
decision: ApprovalDecision,
scope_id: Optional[str] = None,
) -> None:
"""Record a ``session``/``always`` decision for reuse this run (or beyond).
@@ -326,12 +350,17 @@ class ApprovalRegistry:
if not decision.approved:
return
# In-memory "this session" grants are keyed per Agent instance
# (scope_id) when available, so a session approval granted on one worker
# cannot silently unlock the same tool for a distinct same-named worker.
session_key = scope_id if scope_id is not None else agent_name
if scope == "session":
try:
from .utils import build_permission_target
target = build_permission_target(tool_name, arguments)
self._session_scoped_targets.add((agent_name, target))
self._session_scoped_targets.add((session_key, target))
except Exception as e: # noqa: BLE001 — best-effort, in-memory only
logger.debug(
"Could not record session approval for tool '%s': %s",
@@ -351,7 +380,7 @@ class ApprovalRegistry:
from .utils import build_permission_target
self._session_scoped_targets.add(
(agent_name, build_permission_target(tool_name, arguments))
(session_key, build_permission_target(tool_name, arguments))
)
except Exception: # noqa: BLE001
pass
@@ -422,6 +451,8 @@ class ApprovalRegistry:
tool_name: str,
arguments: Dict,
force: bool = False,
auto_approve_scope: Optional[str] = None,
scope_id: Optional[str] = None,
) -> ApprovalDecision:
"""Synchronous approval — used by ``Agent._execute_tool_impl``.
@@ -429,33 +460,46 @@ class ApprovalRegistry:
registered as requiring approval (e.g. a per-agent ``PermissionManager``
``ask`` rule). It applies only to this call and never mutates shared
registry state, so it cannot leak an approval gate onto other agents.
``auto_approve_scope`` is the unique per-instance key used *only* for
the skill auto-approval check. It defaults to ``agent_name`` so callers
that don't pass it keep the old behaviour; passing a per-instance id
(e.g. ``Agent._approval_scope_id``) prevents skill grants from leaking
across unrelated agents that share the same display name.
``scope_id`` is the same per-instance key applied to the in-memory
"this session" grant store, so a human "[s] this session" approval on
one agent can't silently unlock the tool for a distinct same-named
agent. ``agent_name`` is still used for name-keyed lookups (per-agent
backend, risk level, ``ask`` rules, durable ``always`` persistence).
"""
auto_scope = auto_approve_scope or agent_name
# Fast-path: not required (checks both global and this agent's scope)
if not force and not self.is_required(tool_name, agent_name):
return ApprovalDecision(approved=True, reason="No approval required")
# Already approved in this context
if self.is_already_approved(tool_name, arguments, agent_name):
if self.is_already_approved(tool_name, arguments, agent_name, scope_id):
return ApprovalDecision(approved=True, reason="Already approved in context")
# "This session" scoped grant covers matching calls for the run
if self._is_session_scoped(agent_name, tool_name, arguments):
self.mark_approved(tool_name, arguments, agent_name)
if self._is_session_scoped(agent_name, tool_name, arguments, scope_id):
self.mark_approved(tool_name, arguments, agent_name, scope_id)
return ApprovalDecision(approved=True, reason="Approved (session)", approver="session")
# Check per-tool auto-approval (G-A fix)
if self.is_auto_approved(tool_name, agent_name):
self.mark_approved(tool_name, arguments, agent_name)
if self.is_auto_approved(tool_name, auto_scope):
self.mark_approved(tool_name, arguments, agent_name, scope_id)
return ApprovalDecision(approved=True, reason="Auto-approved (skill)", approver="skill")
# Env auto-approve
if self.is_env_auto_approve():
self.mark_approved(tool_name, arguments, agent_name)
self.mark_approved(tool_name, arguments, agent_name, scope_id)
return ApprovalDecision(approved=True, reason="Auto-approved (env)", approver="env")
# YAML auto-approve
if self.is_yaml_approved(tool_name):
self.mark_approved(tool_name, arguments, agent_name)
self.mark_approved(tool_name, arguments, agent_name, scope_id)
return ApprovalDecision(approved=True, reason="Auto-approved (yaml)", approver="yaml")
# Delegate to backend
@@ -479,8 +523,8 @@ class ApprovalRegistry:
)
if decision.approved:
self.mark_approved(tool_name, arguments, agent_name)
self._persist_scoped_decision(agent_name, tool_name, arguments, decision)
self.mark_approved(tool_name, arguments, agent_name, scope_id)
self._persist_scoped_decision(agent_name, tool_name, arguments, decision, scope_id)
return decision
async def approve_async(
@@ -489,35 +533,39 @@ class ApprovalRegistry:
tool_name: str,
arguments: Dict,
force: bool = False,
auto_approve_scope: Optional[str] = None,
scope_id: Optional[str] = None,
) -> ApprovalDecision:
"""Asynchronous approval — used by async tool execution path.
See :meth:`approve_sync` for the ``force`` semantics (per-call gate,
no shared-state mutation).
See :meth:`approve_sync` for the ``force``, ``auto_approve_scope`` and
``scope_id`` semantics (per-call gate, no shared-state mutation,
per-instance session-grant keying).
"""
auto_scope = auto_approve_scope or agent_name
# Fast-path: not required (checks both global and this agent's scope)
if not force and not self.is_required(tool_name, agent_name):
return ApprovalDecision(approved=True, reason="No approval required")
if self.is_already_approved(tool_name, arguments, agent_name):
if self.is_already_approved(tool_name, arguments, agent_name, scope_id):
return ApprovalDecision(approved=True, reason="Already approved in context")
# "This session" scoped grant covers matching calls for the run
if self._is_session_scoped(agent_name, tool_name, arguments):
self.mark_approved(tool_name, arguments, agent_name)
if self._is_session_scoped(agent_name, tool_name, arguments, scope_id):
self.mark_approved(tool_name, arguments, agent_name, scope_id)
return ApprovalDecision(approved=True, reason="Approved (session)", approver="session")
# Check per-tool auto-approval (G-A fix)
if self.is_auto_approved(tool_name, agent_name):
self.mark_approved(tool_name, arguments, agent_name)
if self.is_auto_approved(tool_name, auto_scope):
self.mark_approved(tool_name, arguments, agent_name, scope_id)
return ApprovalDecision(approved=True, reason="Auto-approved (skill)", approver="skill")
if self.is_env_auto_approve():
self.mark_approved(tool_name, arguments, agent_name)
self.mark_approved(tool_name, arguments, agent_name, scope_id)
return ApprovalDecision(approved=True, reason="Auto-approved (env)", approver="env")
if self.is_yaml_approved(tool_name):
self.mark_approved(tool_name, arguments, agent_name)
self.mark_approved(tool_name, arguments, agent_name, scope_id)
return ApprovalDecision(approved=True, reason="Auto-approved (yaml)", approver="yaml")
backend = self.get_backend(agent_name)
@@ -537,6 +585,6 @@ class ApprovalRegistry:
decision = ApprovalDecision(approved=False, reason="Approval timed out")
if decision.approved:
self.mark_approved(tool_name, arguments, agent_name)
self._persist_scoped_decision(agent_name, tool_name, arguments, decision)
self.mark_approved(tool_name, arguments, agent_name, scope_id)
self._persist_scoped_decision(agent_name, tool_name, arguments, decision, scope_id)
return decision
@@ -114,6 +114,108 @@ def build_permission_target(
return f"tool:{tool_name}"
def build_diff_preview(
tool_name: str, arguments: Optional[Dict[str, Any]] = None
) -> Optional[str]:
"""Build a readable unified diff for a file-mutating tool call.
Lets an approval prompt show the *actual* pending change (path plus
``+``/``-`` hunks) instead of a truncated argument dump. Uses only the
stdlib :mod:`difflib`; no new dependencies. Returns ``None`` for tools that
are not file mutations (or when the diff cannot be computed) so callers can
fall back to the existing argument summary.
Supported tools:
* ``edit_file`` — targeted ``old_string`` -> ``new_string`` replacement at
``filepath``/``path``. Honours ``replace_all`` (default ``False`` =
first occurrence only) so the preview matches what ``edit_file`` applies.
* ``acp_edit_file`` — whole-file replace: ``new_content`` at ``filepath``
diffed against the current on-disk file.
* ``write_file`` / ``acp_create_file`` — new ``content`` against the
current on-disk file (empty when the file does not yet exist).
* ``apply_patch`` — the ``patch`` text is already a unified diff, returned
verbatim.
"""
import difflib
import os
args = arguments or {}
def _path() -> str:
for key in _PATH_KEYS:
value = args.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return tool_name
def _read_existing(path: str) -> str:
try:
with open(path, "r", encoding="utf-8") as fh:
return fh.read()
except (OSError, UnicodeDecodeError):
return ""
def _unified(old: str, new: str, path: str) -> Optional[str]:
diff = "".join(
difflib.unified_diff(
old.splitlines(keepends=True),
new.splitlines(keepends=True),
fromfile=f"{path} (before)",
tofile=f"{path} (after)",
n=3,
)
)
return diff or None
try:
if tool_name == "edit_file":
old_string = args.get("old_string")
new_string = args.get("new_string")
if not isinstance(old_string, str) or not isinstance(new_string, str):
return None
path = _path()
existing = _read_existing(path)
if existing and old_string and old_string in existing:
# Mirror ``edit_file`` semantics: ``replace_all`` defaults to
# False (first occurrence only), so the preview must not show
# every occurrence changing when only the first will be edited.
if args.get("replace_all"):
new_content = existing.replace(old_string, new_string)
else:
new_content = existing.replace(old_string, new_string, 1)
return _unified(existing, new_content, path)
return _unified(old_string, new_string, path)
if tool_name == "acp_edit_file":
# ACP edits are whole-file replacements: ``new_content`` at
# ``filepath`` (no old_string/new_string contract).
new_content = args.get("new_content")
if not isinstance(new_content, str):
return None
path = _path()
existing = _read_existing(path) if os.path.exists(path) else ""
return _unified(existing, new_content, path)
if tool_name in ("write_file", "acp_create_file"):
content = args.get("content")
if not isinstance(content, str):
return None
path = _path()
existing = _read_existing(path) if os.path.exists(path) else ""
return _unified(existing, content, path)
if tool_name == "apply_patch":
patch = args.get("patch")
if isinstance(patch, str) and patch.strip():
return patch
return None
except Exception: # noqa: BLE001 — preview is advisory; never break approval
return None
return None
def run_coroutine_safely(
coro: Awaitable[T],
timeout: Optional[float] = None
@@ -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
@@ -41,6 +41,8 @@ __all__ = [
"Subscriber",
"get_default_bus",
"set_default_bus",
"EventLogProtocol",
"SqliteEventLog",
]
_LAZY_IMPORTS = {
@@ -50,6 +52,8 @@ _LAZY_IMPORTS = {
"Subscriber": ("praisonaiagents.bus.bus", "Subscriber"),
"get_default_bus": ("praisonaiagents.bus.bus", "get_default_bus"),
"set_default_bus": ("praisonaiagents.bus.bus", "set_default_bus"),
"EventLogProtocol": ("praisonaiagents.bus.event_log", "EventLogProtocol"),
"SqliteEventLog": ("praisonaiagents.bus.event_log", "SqliteEventLog"),
}
__getattr__ = create_lazy_getattr(_LAZY_IMPORTS, __name__)
@@ -63,6 +63,9 @@ class EventBus:
self._lock = threading.RLock()
self._event_history: List[Event] = []
self._max_history = 1000
# Durable sinks (e.g. SqliteEventLog). Opt-in: a bare Agent() attaches
# none, preserving the zero-overhead in-memory path.
self._sinks: List[Any] = []
def subscribe(
self,
@@ -169,8 +172,9 @@ class EventBus:
Returns:
The published Event object
"""
# Fast path: if no subscribers, return a minimal event without expensive operations
if not self._subscribers:
# Fast path: if no subscribers and no durable sinks, return a minimal
# event without expensive operations.
if not self._subscribers and not self._sinks:
# Convert EventType enum to string
type_str = event_type.value if isinstance(event_type, EventType) else event_type
return Event(
@@ -204,6 +208,10 @@ class EventBus:
"""
logger.debug(f"Publishing event: {event.type}")
# Durable persistence first (best-effort): a durable sink must receive
# the event even when there are no in-memory subscribers.
self._dispatch_to_sinks(event)
# Fast path: if no subscribers, skip expensive work
if not self._subscribers:
return event
@@ -259,8 +267,9 @@ class EventBus:
"""
type_str = event_type.value if isinstance(event_type, EventType) else event_type
# Fast path: if no subscribers, return a minimal event without expensive operations
if not self._subscribers:
# Fast path: if no subscribers and no durable sinks, return a minimal
# event without expensive operations.
if not self._subscribers and not self._sinks:
return Event(
type=type_str,
data=data or {},
@@ -277,6 +286,13 @@ class EventBus:
logger.debug(f"Publishing async event: {event.type}")
# Durable persistence first (best-effort).
self._dispatch_to_sinks(event)
# Fast path: if no subscribers, skip expensive work
if not self._subscribers:
return event
# Store in history
with self._lock:
self._event_history.append(event)
@@ -338,6 +354,54 @@ class EventBus:
with self._lock:
self._subscribers.clear()
def attach_sink(self, sink: Any) -> None:
"""Attach a durable sink that persists every published event.
A sink is any object with an ``append(event)`` method (e.g.
:class:`~praisonaiagents.bus.event_log.SqliteEventLog`). Sinks receive
every event regardless of subscriber filters, and their failures are
swallowed so a full/slow log never breaks the originating turn.
Args:
sink: Object exposing ``append(event: Event) -> None``.
"""
with self._lock:
if sink not in self._sinks:
self._sinks.append(sink)
def detach_sink(self, sink: Any) -> bool:
"""Detach a previously attached sink.
Args:
sink: The sink object to remove.
Returns:
True if the sink was attached and removed, False otherwise.
"""
with self._lock:
try:
self._sinks.remove(sink)
return True
except ValueError:
return False
@property
def has_sinks(self) -> bool:
"""Check if there are any durable sinks (lock-free for performance)."""
return bool(self._sinks)
def _dispatch_to_sinks(self, event: Event) -> None:
"""Best-effort durable persistence: never raises into the caller."""
if not self._sinks:
return
with self._lock:
sinks = list(self._sinks)
for sink in sinks:
try:
sink.append(event)
except Exception as e: # pragma: no cover - defensive
logger.debug(f"Error in event sink: {e}")
@property
def subscriber_count(self) -> int:
"""Get the number of subscribers."""
@@ -0,0 +1,326 @@
"""
Durable, queryable per-session lifecycle event log.
The :class:`~praisonaiagents.bus.bus.EventBus` keeps only a bounded in-memory
ring buffer, so its history is lost on restart and cannot be read back per
session. This module adds a durable, append-only *sink* that persists the very
same :class:`~praisonaiagents.bus.event.Event` shapes to SQLite, keyed by
``session_id`` with a monotonic per-session ``seq``, so operators and dashboards
can read one ordered timeline that survives a restart:
events(session_id, after_seq) -> ordered list[Event]
Design notes (mirrors the durable ``runs/sqlite_ledger.py`` posture):
- **Opt-in.** A bare ``Agent()`` attaches no sink, so the zero-overhead
in-memory path is untouched. The gateway wires a sink in explicitly.
- **Best-effort.** :meth:`SqliteEventLog.append` never raises into the caller;
a full/slow/broken log must never break the turn that produced the event.
- **Bounded.** :meth:`SqliteEventLog.prune` trims by age and row count.
- **Reuse, don't reinvent.** Persists the existing ``Event``/``EventType``
shapes; adds no new status vocabulary.
"""
from __future__ import annotations
import json
import os
import sqlite3
import threading
import time
from typing import Any, List, Optional, Protocol, runtime_checkable
from .._logging import get_logger
from .event import Event, EventType
logger = get_logger(__name__)
__all__ = ["EventLogProtocol", "SqliteEventLog"]
@runtime_checkable
class EventLogProtocol(Protocol):
"""Durable sink for lifecycle events, keyed by session with a cursor.
Implementations persist events published to an :class:`EventBus` so the
lifecycle timeline of a session survives a restart and can be read back in
order with a cursor. ``append`` must be best-effort (never raise into the
caller).
"""
def append(self, event: Event) -> None:
"""Durably record ``event``. Best-effort: never raises into caller."""
...
def query(
self, session_id: str, after_seq: int = 0, limit: int = 500
) -> List[Event]:
"""Return events for ``session_id`` with ``seq > after_seq``, ordered."""
...
def prune(self, *, older_than_days: float, max_rows: int) -> int:
"""Delete old/excess rows. Returns the number of rows removed."""
...
def _session_id_of(event: Event) -> str:
"""Best-effort resolution of the session an event belongs to.
Prefers an explicit ``session_id`` in ``data``/``metadata`` and falls back
to ``event.source`` (many publish sites pass the session id there).
"""
for container in (event.data, event.metadata):
if isinstance(container, dict):
sid = container.get("session_id")
if sid:
return str(sid)
if event.source:
return str(event.source)
return ""
_SCHEMA = """
CREATE TABLE IF NOT EXISTS events (
session_id TEXT NOT NULL,
seq INTEGER NOT NULL,
ts REAL NOT NULL,
type TEXT NOT NULL,
event_id TEXT,
source TEXT,
data_json TEXT,
metadata_json TEXT,
PRIMARY KEY (session_id, seq)
);
CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
CREATE TABLE IF NOT EXISTS event_seq (
session_id TEXT PRIMARY KEY,
last_seq INTEGER NOT NULL
);
"""
_MIGRATIONS = (
"ALTER TABLE events ADD COLUMN metadata_json TEXT",
)
# Seed the per-session high-water-mark from any pre-existing event rows so a
# database created before ``event_seq`` existed continues numbering above its
# current MAX(seq) instead of colliding on the primary key.
_SEED_HWM = (
"INSERT INTO event_seq (session_id, last_seq) "
"SELECT session_id, MAX(seq) FROM events "
"WHERE session_id NOT IN (SELECT session_id FROM event_seq) "
"GROUP BY session_id"
)
class SqliteEventLog(EventLogProtocol):
"""Durable, restart-safe lifecycle event log backed by SQLite.
A durable sibling of :class:`~praisonaiagents.runs.sqlite_ledger.SQLiteRunLedger`.
One row per event ``(session_id, seq, ts, type, event_id, source, data_json,
metadata_json)``, append-only, WAL, bounded. Per-session ``seq`` is allocated
from a persistent ``event_seq`` high-water-mark table so cursors stay
monotonic across restarts and full prunes. Attach it to a bus with
``bus.attach_sink(log)``.
Args:
db_path: Path to the SQLite file. Defaults to
``<runs_dir>/events.sqlite``. Use ``":memory:"`` for tests.
"""
def __init__(self, db_path: Optional[str] = None) -> None:
if db_path is None:
from ..paths import get_runs_dir
runs_dir = get_runs_dir()
os.makedirs(runs_dir, exist_ok=True)
db_path = str(runs_dir / "events.sqlite")
elif db_path != ":memory:":
parent = os.path.dirname(db_path)
if parent:
os.makedirs(parent, exist_ok=True)
self._db_path = db_path
self._lock = threading.RLock()
self._conn = sqlite3.connect(
db_path, check_same_thread=False, isolation_level=None
)
self._conn.row_factory = sqlite3.Row
with self._lock:
self._conn.execute("PRAGMA busy_timeout = 5000")
if db_path != ":memory:":
try:
self._conn.execute("PRAGMA journal_mode = WAL")
self._conn.execute("PRAGMA synchronous = NORMAL")
except sqlite3.OperationalError: # pragma: no cover - defensive
pass
self._conn.executescript(_SCHEMA)
self._migrate()
def _migrate(self) -> None:
"""Best-effort additive migrations for pre-existing databases."""
for stmt in _MIGRATIONS:
try:
self._conn.execute(stmt)
except sqlite3.OperationalError:
# Column already exists (fresh schema) — nothing to do.
pass
try:
self._conn.execute(_SEED_HWM)
except sqlite3.OperationalError: # pragma: no cover - defensive
pass
# ── public API (EventLogProtocol) ─────────────────────────────────
def append(self, event: Event) -> None:
"""Durably record ``event``. Best-effort: never raises into caller.
A monotonic per-session ``seq`` is assigned from a persistent
high-water-mark table (``event_seq``) inside a serialising
``BEGIN IMMEDIATE`` transaction. This makes allocation atomic even when
two :class:`SqliteEventLog` instances share one database file, and keeps
the cursor monotonic across restarts *and* after a full prune (deleting
every row for a session never rewinds its counter).
"""
try:
session_id = _session_id_of(event)
try:
data_json = json.dumps(event.data)
except (TypeError, ValueError):
data_json = json.dumps(event.data, default=str)
metadata_json = self._dumps_or_none(event.metadata)
with self._lock:
try:
self._conn.execute("BEGIN IMMEDIATE")
row = self._conn.execute(
"SELECT last_seq FROM event_seq WHERE session_id = ?",
(session_id,),
).fetchone()
next_seq = (int(row["last_seq"]) if row else 0) + 1
self._conn.execute(
"INSERT INTO event_seq (session_id, last_seq) VALUES (?, ?) "
"ON CONFLICT(session_id) DO UPDATE SET last_seq = excluded.last_seq",
(session_id, next_seq),
)
self._conn.execute(
"INSERT INTO events "
"(session_id, seq, ts, type, event_id, source, "
"data_json, metadata_json) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
session_id,
next_seq,
float(event.timestamp),
event.type,
event.id,
event.source,
data_json,
metadata_json,
),
)
self._conn.execute("COMMIT")
except Exception:
try:
self._conn.execute("ROLLBACK")
except Exception: # pragma: no cover - defensive
pass
raise
except Exception as exc: # best-effort: never break the originating turn
logger.debug("SqliteEventLog.append skipped: %s", exc)
@staticmethod
def _dumps_or_none(value: Any) -> Optional[str]:
"""Serialise ``value`` to JSON, coercing non-native types; ``None`` if empty."""
if not value:
return None
try:
return json.dumps(value)
except (TypeError, ValueError):
return json.dumps(value, default=str)
def query(
self, session_id: str, after_seq: int = 0, limit: int = 500
) -> List[Event]:
"""Return events for ``session_id`` with ``seq > after_seq``, in order."""
try:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM events WHERE session_id = ? AND seq > ? "
"ORDER BY seq ASC LIMIT ?",
(session_id, int(after_seq), int(limit)),
).fetchall()
return [self._row_to_event(r) for r in rows]
except Exception as exc: # pragma: no cover - defensive
logger.debug("SqliteEventLog.query failed: %s", exc)
return []
def prune(self, *, older_than_days: float, max_rows: int) -> int:
"""Delete rows older than ``older_than_days`` and beyond ``max_rows``.
Returns the number of rows deleted. Best-effort; returns ``0`` on error.
"""
removed = 0
try:
with self._lock:
if older_than_days and older_than_days > 0:
cutoff = time.time() - older_than_days * 86400.0
cur = self._conn.execute(
"DELETE FROM events WHERE ts < ?", (cutoff,)
)
removed += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
if max_rows and max_rows > 0:
total = self._conn.execute(
"SELECT COUNT(*) AS c FROM events"
).fetchone()["c"]
excess = int(total) - int(max_rows)
if excess > 0:
cur = self._conn.execute(
"DELETE FROM events WHERE rowid IN ("
"SELECT rowid FROM events ORDER BY ts ASC LIMIT ?)",
(excess,),
)
removed += (
cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
)
except Exception as exc: # pragma: no cover - defensive
logger.debug("SqliteEventLog.prune failed: %s", exc)
return removed
def close(self) -> None:
"""Close the underlying database connection."""
with self._lock:
try:
self._conn.close()
except Exception: # pragma: no cover - defensive
pass
# ── helpers ───────────────────────────────────────────────────────
@staticmethod
def _row_to_event(row: sqlite3.Row) -> Event:
try:
data = json.loads(row["data_json"]) if row["data_json"] else {}
except (ValueError, TypeError):
data = {}
# Restore caller-provided metadata, then overlay the durable cursor
# fields (seq, session_id) so correlation/diagnostic keys survive the
# round trip.
metadata: dict = {}
raw_meta = row["metadata_json"] if "metadata_json" in row.keys() else None
if raw_meta:
try:
loaded = json.loads(raw_meta)
if isinstance(loaded, dict):
metadata.update(loaded)
except (ValueError, TypeError):
pass
metadata["seq"] = row["seq"]
metadata["session_id"] = row["session_id"]
return Event(
id=row["event_id"] or "",
type=row["type"] or EventType.CUSTOM.value,
data=data,
timestamp=row["ts"],
source=row["source"],
metadata=metadata,
)
@@ -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())
@@ -245,6 +245,83 @@ def _find_config_file() -> Optional[Path]:
return None
def _discover_config_files() -> List[Path]:
"""Discover all config files ordered lowest→highest precedence.
Returns an ordered list ``[global, …ancestors…, project]`` so callers can
deep-merge left-to-right: the user-global ``~/.praisonai/config.*`` provides
defaults, project configs discovered by walking up from the current
directory override them, and the nearest (deepest) project config wins.
A project config that sets one key therefore keeps every global default for
the rest, fixing the previous first-file-wins behaviour where any project
config silently shadowed the global one.
For each directory only the first matching filename (in ``config.toml``,
``config.yaml``, ``config.yml``, ``praisonai.{toml,yaml,yml}`` order) is
used, preserving today's per-location precedence.
Returns:
Ordered list of existing config paths (lowest precedence first). Empty
when no config files exist anywhere.
"""
files: List[Path] = []
# User global location (lowest precedence).
from ..paths import get_data_dir
data_dir = get_data_dir()
for global_path in (
data_dir / "config.toml",
data_dir / "config.yaml",
data_dir / "config.yml",
):
if global_path.exists():
files.append(global_path)
break
# Project configs discovered by walking up from cwd to the filesystem root.
# Collected root→cwd so the nearest (deepest) config is folded last and wins.
ancestor_files: List[Path] = []
cwd = Path.cwd()
for directory in [cwd, *cwd.parents]:
project_data = directory / ".praisonai"
candidates = [
project_data / "config.toml",
project_data / "config.yaml",
project_data / "config.yml",
directory / "praisonai.toml",
directory / "praisonai.yaml",
directory / "praisonai.yml",
]
for path in candidates:
if path.exists():
ancestor_files.append(path)
break
ancestor_files.reverse()
files.extend(ancestor_files)
return files
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
"""Recursively merge ``override`` onto ``base`` (dicts recurse, else replace).
Nested mappings are merged key-by-key so a higher-precedence layer can set a
single nested key without discarding sibling keys from lower layers. Any
non-dict value (including lists) at a given key replaces the base value.
Returns a new dict; inputs are not mutated.
"""
result = dict(base)
for key, value in override.items():
existing = result.get(key)
if isinstance(existing, dict) and isinstance(value, dict):
result[key] = _deep_merge(existing, value)
else:
result[key] = value
return result
def _parse_toml(path: Path) -> Dict[str, Any]:
"""Parse TOML file with fallback for Python < 3.11.
@@ -295,16 +372,8 @@ def _parse_yaml(path: Path) -> Dict[str, Any]:
return data if isinstance(data, dict) else {}
def _load_config() -> Dict[str, Any]:
"""Load configuration from file (TOML or the unified YAML surface).
Returns:
Config dict (empty if no config file found)
"""
config_path = _find_config_file()
if config_path is None:
return {}
def _parse_config_file(config_path: Path) -> Dict[str, Any]:
"""Parse a single config file (TOML or YAML), returning {} on failure."""
try:
if config_path.suffix.lower() in (".yaml", ".yml"):
return _parse_yaml(config_path)
@@ -315,6 +384,31 @@ def _load_config() -> Dict[str, Any]:
return {}
def _load_config() -> Dict[str, Any]:
"""Load configuration by deep-merging all discovered config files.
Files are folded lowest→highest precedence (global first, nearest project
last) so a project config selectively overrides global defaults instead of
shadowing them entirely. When exactly one file exists the merged result
equals parsing that file alone, preserving backward compatibility.
Returns:
Merged config dict (empty if no config file found)
"""
config_paths = _discover_config_files()
if not config_paths:
return {}
# Fast path: a single file needs no merge and matches prior behaviour.
if len(config_paths) == 1:
return _parse_config_file(config_paths[0])
merged: Dict[str, Any] = {}
for config_path in config_paths:
merged = _deep_merge(merged, _parse_config_file(config_path))
return merged
def _dict_to_plugins_config(data: Dict[str, Any]) -> PluginsConfig:
"""Convert dict to PluginsConfig.
@@ -596,15 +690,19 @@ def get_plugin_options() -> Dict[str, Dict[str, Any]]:
def _config_write_target() -> Path:
"""Resolve the config file the CLI should write plugin enable/disable to.
Returns the existing config file if one is already present (so the CLI
mutates the *same* file the runtime reads), otherwise the project-local
``.praisonai/config.yaml`` — the unified surface — as the default target.
This closes the config split-brain where the CLI wrote a JSON file the
runtime never read.
Returns the *highest-precedence* existing config file (the one that wins
after the deep merge) so the CLI mutates the same file the runtime honours.
Using ``_discover_config_files()[-1]`` — rather than ``_find_config_file()``
which only sees cwd + global — ensures that when a nearer ancestor config
overrides the global, the CLI writes that ancestor file instead of a
lower-precedence global whose change would be silently overridden on reload.
Falls back to the project-local ``.praisonai/config.yaml`` — the unified
surface — when no config exists yet. This closes the config split-brain
where the CLI wrote a file the runtime never read.
"""
existing = _find_config_file()
if existing is not None:
return existing
config_paths = _discover_config_files()
if config_paths:
return config_paths[-1]
return get_project_data_dir() / "config.yaml"
@@ -649,7 +747,10 @@ def set_plugin_enabled(name: str, enabled: bool) -> Path:
The path of the config file that was written.
"""
target = _config_write_target()
raw = _load_config() if _find_config_file() is not None else {}
# Seed from the *target* file only (not the merged view) so we round-trip the
# exact file we write back to; merging in lower-precedence globals here would
# persist inherited keys into the target and defeat the fall-through design.
raw = _parse_config_file(target) if target.exists() else {}
if not isinstance(raw, dict):
raw = {}
@@ -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
@@ -22,6 +22,23 @@ from ..protocols import KnowledgeStoreProtocol
logger = logging.getLogger(__name__)
def _default_chroma_path() -> str:
"""Absolute default persist directory for the Chroma vector store.
The historical default was the relative string ``"knowledge_db"`` which
resolves against the current working directory. Two projects (or a test
harness that ``cd``s around) then share one on-disk sqlite, and Chroma's
rust bindings can panic on a corrupt/short slice (issue #4376). Persisting
under the project's ``.praisonai/knowledge/chroma`` keeps the store
absolute and isolated per project.
"""
try:
from ...paths import get_project_knowledge_dir
return str(get_project_knowledge_dir() / "chroma")
except Exception:
return str(os.path.join(os.getcwd(), ".praisonai", "knowledge", "chroma"))
def create_mem0_knowledge_adapter(**kwargs) -> KnowledgeStoreProtocol:
"""
Factory function to create Mem0 knowledge adapter.
@@ -119,14 +136,29 @@ class ChromaKnowledgeAdapter:
vector_config = config.get("vector_store", {}).get("config", {})
collection_name = vector_config.get("collection_name", "praisonai_knowledge")
persist_dir = vector_config.get("path", "knowledge_db")
persist_dir = vector_config.get("path") or _default_chroma_path()
os.makedirs(persist_dir, exist_ok=True)
# Initialize ChromaDB client
self.client = chromadb.PersistentClient(
path=persist_dir,
settings=chromadb.config.Settings(anonymized_telemetry=False)
)
# Initialize ChromaDB client.
# ChromaDB's rust bindings can raise a pyo3 PanicException (a
# BaseException, not Exception) on a corrupt/locked sqlite store; catch
# BaseException and re-raise as a Python RuntimeError so callers stay
# alive and get an actionable persist-path hint (issue #4376).
try:
self.client = chromadb.PersistentClient(
path=persist_dir,
settings=chromadb.config.Settings(anonymized_telemetry=False)
)
except (KeyboardInterrupt, SystemExit, GeneratorExit):
# Interpreter control-flow signals must propagate untouched so a
# Ctrl+C / interpreter shutdown is not masked as a backend failure.
raise
except BaseException as exc:
raise RuntimeError(
f"Chroma persist failed at {persist_dir!r} "
f"({type(exc).__name__}: {exc}). Pass a fresh directory via "
f"knowledge config vector_store.config.path."
) from exc
# Initialize collection
try:
@@ -95,8 +95,12 @@ class Knowledge:
# Use deterministic collection name for persistence across restarts
# Only generate unique name if explicitly requested via config
default_collection = "praisonai_knowledge"
from ..paths import get_project_data_dir
persist_dir = str(get_project_data_dir())
# Default to an absolute, per-project ``.praisonai/knowledge/chroma``
# directory so unrelated projects/harnesses don't share one on-disk
# sqlite store (issue #4376). A user-supplied
# vector_store.config.path still overrides this default below.
from ..paths import get_project_knowledge_dir
persist_dir = str(get_project_knowledge_dir() / "chroma")
# Create persistent client config (protocol-driven)
base_config = {
@@ -532,6 +536,24 @@ class Knowledge:
all_results.extend(result.get('results', []))
except Exception as e:
logger.warning(f"Failed to process file {file_path}: {e}")
except (KeyboardInterrupt, SystemExit, GeneratorExit):
# Interpreter control-flow signals must propagate
# untouched so a Ctrl+C / interpreter shutdown is
# not masked as a backend failure.
raise
except BaseException as e:
# A native backend (e.g. Chroma's rust bindings)
# can raise a pyo3 PanicException, which is a
# BaseException and would otherwise unwind and
# kill the process. Re-raise as a Python
# RuntimeError so callers stay alive (issue #4376).
logger.error(
"Knowledge backend crashed while indexing %s: %s",
file_path, e,
)
raise RuntimeError(
f"Knowledge indexing backend crashed on {file_path}"
) from e
if not all_results:
logger.warning(f"No supported files found in directory: {input_path}")
@@ -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",
]
+167 -17
View File
@@ -492,6 +492,14 @@ Respond with ONLY a valid JSON tool call in this format:
self.max_reflect = extra_settings.get('max_reflect', 3)
self.min_reflect = extra_settings.get('min_reflect', 1)
self.reasoning_steps = extra_settings.get('reasoning_steps', False)
# Unified, provider-portable reasoning-effort control (Issue #4452).
# Accepts a graded level (off|minimal|low|medium|high) or a legacy
# ``thinking_budget`` int; both normalise to one internal value that is
# translated to each provider's native parameter in
# ``_build_completion_params``. ``None``/``off`` is a zero-overhead no-op.
self.reasoning_effort = extra_settings.get(
'reasoning_effort', extra_settings.get('thinking_budget')
)
self.metrics = extra_settings.get('metrics', False)
# Auto-detect XML tool format for known models, or allow manual override
self.xml_tool_format = extra_settings.get('xml_tool_format', 'auto')
@@ -646,6 +654,16 @@ Respond with ONLY a valid JSON tool call in this format:
if any(url and ("ollama" in url.lower() or ":11434" in url) for url in base_urls):
return "ollama"
# Substring fallback for routed models whose provider is not the first
# path segment (e.g. bedrock/anthropic.claude-*, vertex_ai/claude-*,
# openrouter/anthropic/*, vertex_ai/gemini-*). Mirrors the substring
# fallback in get_provider_adapter so these get the correct adapter
# (and its streaming guard) instead of falling through to "openai".
if "claude" in model_lower or "anthropic" in model_lower:
return "anthropic"
if "gemini" in model_lower:
return "gemini"
# Default fallback
return "openai"
@@ -2964,6 +2982,7 @@ Respond with ONLY a valid JSON tool call in this format:
response_text = resp["choices"][0]["message"]["content"]
final_response = resp
_final_llm_response = resp # Store for token usage extraction
self._record_finish_reason(resp)
# Emit StreamEvent for reasoning content if callback provided
if _emit and reasoning_content:
@@ -3191,6 +3210,7 @@ Respond with ONLY a valid JSON tool call in this format:
)
)
_final_llm_response = final_response # Store for token usage extraction
self._record_finish_reason(final_response)
# Handle None content from Gemini
response_content = final_response["choices"][0]["message"].get("content")
response_text = response_content if response_content is not None else ""
@@ -3380,6 +3400,7 @@ Respond with ONLY a valid JSON tool call in this format:
)
)
_final_llm_response = final_response # Store for token usage extraction
self._record_finish_reason(final_response)
# Handle None content from Gemini
response_content = final_response["choices"][0]["message"].get("content")
response_text = response_content if response_content is not None else ""
@@ -4559,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."""
@@ -4585,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}")
@@ -4768,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):
@@ -4775,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)
@@ -4830,6 +4883,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
**{k:v for k,v in kwargs.items() if k != 'reasoning_steps'}
)
)
self._record_finish_reason(resp)
reasoning_content = resp["choices"][0]["message"].get("provider_specific_fields", {}).get("reasoning_content")
response_text = resp["choices"][0]["message"]["content"]
@@ -4938,6 +4992,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
**{k:v for k,v in kwargs.items() if k != 'reasoning_steps'}
)
)
self._record_finish_reason(tool_response)
# Handle None content from Gemini
response_content = tool_response.choices[0].message.get("content")
response_text = response_content if response_content is not None else ""
@@ -5018,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()
@@ -5025,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
@@ -5108,6 +5172,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
**{k:v for k,v in kwargs.items() if k != 'reasoning_steps'}
)
)
self._record_finish_reason(resp)
reasoning_content = resp["choices"][0]["message"].get("provider_specific_fields", {}).get("reasoning_content")
response_text = resp["choices"][0]["message"]["content"]
@@ -5159,6 +5224,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
**{k:v for k,v in kwargs.items() if k != 'reasoning_steps'}
)
)
self._record_finish_reason(resp)
response_text = resp["choices"][0]["message"].get("content") or ""
# If the response also contains new tool_calls, treat this as a
# tool-calling round rather than a final answer (Anthropic pattern)
@@ -5633,6 +5699,47 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.warning(f"Failed to track token usage: {e}")
return None
def _record_finish_reason(self, response: Union[Dict[str, Any], Any]) -> None:
"""Classify the provider ``finish_reason``/refusal and record it.
Sets ``self._last_stop_reason`` to a distinct provider block/refusal/
truncation reason (``content_filtered | refused | length_truncated``)
when the last completion was blocked, so a blocked/refused/truncated turn
is surfaced as an explicit terminal reason instead of a silent empty
``completed``. Only updates when the reason is still ``"completed"`` so a
prior ``max_steps`` (sticky truncation) is never downgraded. Absent or
unrecognised finish reasons are a no-op — zero overhead on success.
"""
try:
finish_reason = None
refusal = None
if isinstance(response, dict):
choices = response.get("choices") or []
if choices:
choice = choices[0]
finish_reason = choice.get("finish_reason")
msg = choice.get("message") or {}
if isinstance(msg, dict):
refusal = msg.get("refusal")
else:
refusal = getattr(msg, "refusal", None)
else:
choices = getattr(response, "choices", None) or []
if choices:
choice = choices[0]
finish_reason = getattr(choice, "finish_reason", None)
msg = getattr(choice, "message", None)
refusal = getattr(msg, "refusal", None) if msg is not None else None
if finish_reason is None and not refusal:
return
from ..agent.run_outcome import classify_finish_reason
reason = classify_finish_reason(finish_reason, refusal)
if reason is not None and self._last_stop_reason == "completed":
self._last_stop_reason = reason
except Exception:
# Never let outcome classification break the response path.
return
def _extract_token_usage(self, response: Union[Dict[str, Any], Any]) -> Optional[TokenUsage]:
"""Extract token usage from LiteLLM response for public API."""
try:
@@ -5680,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
@@ -5766,10 +5902,24 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
'max_tool_calls_per_turn', 'parallel_tool_calls', # Tool execution settings
'in_loop_compaction', 'clear_threshold_pct', 'compact_threshold_pct', # In-loop context management
'keep_recent_tool_results', # In-loop context management
'reasoning_effort', 'thinking_budget', # Reasoning-effort (translated below, Issue #4452)
]
for param in internal_params:
params.pop(param, None)
# Translate the unified reasoning-effort level to the target provider's
# native request parameter (Issue #4452): OpenAI/xAI reasoning models get
# ``reasoning_effort``, Anthropic/Gemini get an extended-thinking budget,
# and models with no reasoning control are left untouched. A per-call
# override wins over the instance-level setting; ``off``/unset is a no-op.
effort = override_params.get('reasoning_effort', self.reasoning_effort)
if effort is not None:
from ..thinking.effort import resolve_reasoning_params
reasoning_params = resolve_reasoning_params(effort, self.model)
# Don't clobber an explicit native param the caller already set.
for key, value in reasoning_params.items():
params.setdefault(key, value)
# Reasoning models (o1/o3/gpt-5.x) require max_completion_tokens and
# reject the legacy max_tokens parameter plus several sampling params.
# Normalize here (after override_params merge) so per-call overrides
@@ -5981,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
@@ -242,9 +242,31 @@ class MCPToolRunner(threading.Thread):
telemetry.track_tool_usage(tool_name, success=is_success, execution_time=execution_time)
def shutdown(self):
"""Signal the thread to shut down."""
"""Signal the thread to shut down.
Queues a sentinel that the request loop consumes to exit cleanly. This
only takes effect once initialization has completed and the loop is
running; use :meth:`stop` for a best-effort teardown when init never
finished (e.g. a timed-out handshake) so the daemon thread and its
stdio child do not leak across construction retries (issue #4375).
"""
self.queue.put(None)
def stop(self):
"""Best-effort teardown for a runner that failed to initialize.
Queues the shutdown sentinel and joins the thread briefly so that if
the handshake eventually completes the request loop exits immediately
instead of leaving a background thread (and its stdio subprocess) alive.
The thread is a daemon, so this never blocks interpreter exit.
"""
try:
self.queue.put(None)
except Exception:
pass
if self.is_alive():
self.join(timeout=1)
class MCP:
"""
Model Context Protocol (MCP) integration for PraisonAI Agents.
@@ -478,9 +500,23 @@ class MCP:
)
self.runner = MCPToolRunner(self.server_params, timeout)
# Wait for initialization
# Wait for initialization. Fail closed on timeout so callers never
# receive a usable-but-empty MCP object that silently degrades an
# Agent into a tool-less chat (issue #4375). Tear the runner down first
# so a blocked handshake does not leave a background thread and stdio
# child process alive across construction retries.
if not self.runner.initialized.wait(timeout=self.timeout):
print(f"Warning: MCP initialization timed out after {self.timeout} seconds")
self.runner.stop()
raise TimeoutError(
f"MCP initialization timed out after {self.timeout} seconds "
f"(command={cmd!r} args={arguments!r})."
)
if getattr(self.runner, "_init_error", None):
self.runner.stop()
raise RuntimeError(
f"MCP initialization failed: {self.runner._init_error} "
f"(command={cmd!r} args={arguments!r})."
)
# Automatically detect if this is an NPX command
base_cmd = os.path.basename(cmd) if isinstance(cmd, str) else cmd
@@ -499,6 +535,25 @@ class MCP:
else:
# Generate tool functions immediately and store them
self._tools = self._apply_tool_filters(self._generate_tool_functions())
# Fail closed if a stdio server initialized but produced no tools.
# Returning an empty iterable here would let Agent(tools=mcp) run as a
# tool-less chat and hallucinate answers (issue #4375). Distinguish the
# two empty cases:
# * the server itself advertised zero tools -> always fail closed,
# even when a filter is configured (the filter removed nothing, so
# it cannot be blamed for the empty result); versus
# * a filter deliberately removed every tool -> leave untouched, as
# that is an intentional configuration by the caller.
server_advertised_tools = bool(getattr(self.runner, "tools", None))
filter_configured = bool(self.allowed_tools or self.disabled_tools)
if not self._tools and not (filter_configured and server_advertised_tools):
err = getattr(self.runner, "_init_error", None)
raise RuntimeError(
f"MCP server produced 0 tools (command={cmd!r} args={arguments!r}). "
f"init_error={err!r}. On Windows pass command='npx.cmd' explicitly "
f"or use a Python MCP server (command=sys.executable, args=[server.py])."
)
def _generate_tool_functions(self) -> List[Callable]:
"""
@@ -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
"""
@@ -23,6 +23,19 @@ from ..protocols import MemoryProtocol
logger = logging.getLogger(__name__)
def sanitize_chroma_metadata(metadata: Dict) -> Dict:
"""Sanitize metadata for ChromaDB - convert to acceptable types."""
sanitized = {}
for k, v in metadata.items():
if v is None:
continue
if isinstance(v, (str, int, float, bool)):
sanitized[k] = v
else:
sanitized[k] = str(v)
return sanitized
def safe_mem0_search(mem0_client, **kwargs) -> List[Dict[str, Any]]:
"""
Defensive wrapper for mem0.search() to handle MongoDB vector store compatibility.
@@ -359,17 +372,7 @@ class ChromaMemoryAdapter:
def _sanitize_metadata(self, metadata: Dict) -> Dict:
"""Sanitize metadata for ChromaDB - convert to acceptable types."""
sanitized = {}
for k, v in metadata.items():
if v is None:
continue
if isinstance(v, (str, int, float, bool)):
sanitized[k] = v
elif isinstance(v, dict):
sanitized[k] = str(v)
else:
sanitized[k] = str(v)
return sanitized
return sanitize_chroma_metadata(metadata)
def _reset_collection(self) -> None:
"""Delete and recreate this adapter's collection, wiping stored memory.
@@ -5,6 +5,7 @@ Lightweight in-memory adapter for testing and development.
No dependencies required - stores data as plain Python lists.
"""
import threading
from typing import Any, Dict, List, Optional
@@ -25,6 +26,13 @@ class InMemoryAdapter:
self._data: List[Dict[str, Any]] = []
self._max_size: int = kwargs.get("max_size", 10_000) # Default limit
self._next_id: int = 0 # Monotonic counter to prevent ID collisions after eviction
# Guards the non-atomic id-increment + append + eviction sequence. Async
# task/knowledge paths offload writes via ``asyncio.to_thread``, so
# parallel callbacks can hit this shared adapter on different worker
# threads concurrently. A re-entrant lock keeps every mutation atomic
# (no duplicate ids / lost entries) at negligible cost for the common
# single-threaded case.
self._lock = threading.RLock()
def _evict_if_needed(self):
"""Evict old entries if we exceed max size (FIFO eviction)."""
@@ -35,50 +43,54 @@ class InMemoryAdapter:
def store_short_term(
self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs
) -> str:
entry: Dict[str, Any] = {
"id": str(self._next_id),
"text": text,
"type": "short",
"metadata": metadata,
}
self._next_id += 1
self._data.append(entry)
self._evict_if_needed()
return entry["id"]
with self._lock:
entry: Dict[str, Any] = {
"id": str(self._next_id),
"text": text,
"type": "short",
"metadata": metadata,
}
self._next_id += 1
self._data.append(entry)
self._evict_if_needed()
return entry["id"]
def search_short_term(
self, query: str, limit: int = 5, **kwargs
) -> List[Dict[str, Any]]:
results = [
e
for e in self._data
if e["type"] == "short" and query.lower() in e["text"].lower()
]
return results[:limit]
with self._lock:
results = [
e
for e in self._data
if e["type"] == "short" and query.lower() in e["text"].lower()
]
return results[:limit]
def store_long_term(
self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs
) -> str:
entry: Dict[str, Any] = {
"id": str(self._next_id),
"text": text,
"type": "long",
"metadata": metadata,
}
self._next_id += 1
self._data.append(entry)
self._evict_if_needed()
return entry["id"]
with self._lock:
entry: Dict[str, Any] = {
"id": str(self._next_id),
"text": text,
"type": "long",
"metadata": metadata,
}
self._next_id += 1
self._data.append(entry)
self._evict_if_needed()
return entry["id"]
def search_long_term(
self, query: str, limit: int = 5, **kwargs
) -> List[Dict[str, Any]]:
results = [
e
for e in self._data
if e["type"] == "long" and query.lower() in e["text"].lower()
]
return results[:limit]
with self._lock:
results = [
e
for e in self._data
if e["type"] == "long" and query.lower() in e["text"].lower()
]
return results[:limit]
def delete_memory(self, memory_id: str, tier: Optional[str] = None, **kwargs) -> bool:
"""Delete a memory by ID. Returns True if an entry was removed.
@@ -87,25 +99,29 @@ class InMemoryAdapter:
tier. IDs here are globally unique (shared monotonic counter), so tier
is only used to preserve the caller's scope, never for disambiguation.
"""
before = len(self._data)
self._data = [
e
for e in self._data
if not (
e.get("id") == str(memory_id)
and (tier is None or e.get("type") == tier)
)
]
return len(self._data) < before
with self._lock:
before = len(self._data)
self._data = [
e
for e in self._data
if not (
e.get("id") == str(memory_id)
and (tier is None or e.get("type") == tier)
)
]
return len(self._data) < before
def reset_short_term(self) -> None:
"""Clear all short-term memories."""
self._data = [e for e in self._data if e.get("type") != "short"]
with self._lock:
self._data = [e for e in self._data if e.get("type") != "short"]
def reset_long_term(self) -> None:
"""Clear all long-term memories."""
self._data = [e for e in self._data if e.get("type") != "long"]
with self._lock:
self._data = [e for e in self._data if e.get("type") != "long"]
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
# Return defensive copy to prevent external mutation of internal state
return [dict(entry) for entry in self._data]
with self._lock:
# Return defensive copy to prevent external mutation of internal state
return [dict(entry) for entry in self._data]
@@ -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,
}
@@ -18,6 +18,7 @@ from .core import MemoryCoreMixin
from .protocols import MemoryProtocol
from .adapters import get_memory_adapter, get_first_available_memory_adapter
from .adapters.sqlite_adapter import SqliteMemoryAdapter
from .adapters.factories import sanitize_chroma_metadata
# Disable litellm telemetry before any imports
os.environ["LITELLM_TELEMETRY"] = "False"
@@ -979,19 +980,8 @@ class Memory(SearchMixin, MemoryCoreMixin):
# -------------------------------------------------------------------------
def _sanitize_metadata(self, metadata: Dict) -> Dict:
"""Sanitize metadata for ChromaDB - convert to acceptable types"""
sanitized = {}
for k, v in metadata.items():
if v is None:
continue
if isinstance(v, (str, int, float, bool)):
sanitized[k] = v
elif isinstance(v, dict):
# Convert dict to string representation
sanitized[k] = str(v)
else:
# Convert other types to string
sanitized[k] = str(v)
return sanitized
from .adapters.factories import sanitize_chroma_metadata
return sanitize_chroma_metadata(metadata)
def store_long_term(
self,
@@ -75,12 +75,15 @@ class ApprovalCallback:
if self.approve_fn is not None:
# Check if function is async
if inspect.iscoroutinefunction(self.approve_fn):
# Run async function synchronously
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(self.approve_fn(plan))
finally:
loop.close()
# Run async function synchronously through the package's shared
# safe bridge, which performs the running-loop check every other
# sync↔async bridge in the codebase does. Hand-rolling a new loop
# here still crashed with "Cannot run the event loop while another
# loop is running" whenever __call__ was reached from inside a
# running loop (e.g. a FastAPI async route), because CPython's
# running-loop flag is thread-global, not per-loop-instance.
from ..utils.async_bridge import run_coroutine_from_any_context
result = run_coroutine_from_any_context(self.approve_fn(plan))
else:
result = self.approve_fn(plan)
@@ -34,6 +34,7 @@ if TYPE_CHECKING:
GateResult,
SchedulerProviderProtocol,
)
from .incidents import Incident, IncidentTracker, error_signature, normalise_error
_module_cache = {}
@@ -142,6 +143,14 @@ def __getattr__(name: str):
_module_cache["SchedulerProviderProtocol"] = SchedulerProviderProtocol
return _module_cache[name]
if name in ("Incident", "IncidentTracker", "error_signature", "normalise_error"):
from .incidents import Incident, IncidentTracker, error_signature, normalise_error
_module_cache["Incident"] = Incident
_module_cache["IncidentTracker"] = IncidentTracker
_module_cache["error_signature"] = error_signature
_module_cache["normalise_error"] = normalise_error
return _module_cache[name]
if name in ("Blueprint", "BlueprintSlot", "BlueprintStoreProtocol"):
from .blueprint_defs import Blueprint, BlueprintSlot, BlueprintStoreProtocol
_module_cache["Blueprint"] = Blueprint
@@ -175,6 +184,10 @@ __all__ = [
"JobStateStoreProtocol",
"GateResult",
"SchedulerProviderProtocol",
"Incident",
"IncidentTracker",
"error_signature",
"normalise_error",
"Blueprint",
"BlueprintSlot",
"BlueprintStoreProtocol",
@@ -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()
@@ -0,0 +1,261 @@
"""
Incident tracking for scheduled jobs — pure, dependency-free decision logic.
A scheduled automation running unattended may silently start failing. Per-run
history (:class:`RunRecord`) records *that* a tick failed, but nothing groups
repeated failures, detects recovery, or de-duplicates an operator alert — so a
broken nightly job either goes unnoticed or (if any alert were wired) re-fires
on every tick (alert fatigue).
This module is the shared "incident brain": it groups failures into durable
:class:`Incident` records keyed by a stable :func:`error_signature`, and a pure
:class:`IncidentTracker` that decides — from the stream of :class:`RunRecord`
observations — **when an operator should actually be told**:
* alert **once** per incident (first failure of a signature),
* **not again** for the same signature until it changes or recovers,
* a changed error message mints a **new** incident (re-alert),
* the next success **resolves** the open incident (single recovery note),
* optionally alert only after **N consecutive** failures (tolerate blips).
The tracker owns only the *policy* (what counts as an incident, when to alert).
It holds no I/O: a wrapper/gateway persists the returned incidents and delivers
the alert via the job's existing ``DeliveryTarget`` — so every execution engine
(gateway tick, wrapper, standalone) shares one incident brain (DRY).
"""
import hashlib
import re
import time
from dataclasses import dataclass, field
from typing import Any, Dict, Literal, Optional
# Normalise volatile tokens out of an error so "429 rate limit (req id abc)" and
# "429 rate limit (req id xyz)" collapse to the *same* signature — otherwise
# every retry mints a "new" incident and re-alerts (the fatigue we prevent).
#
# We must NOT blanket-delete every digit run: a short number is frequently the
# *category* of the failure (HTTP status 404 vs 500, error/exit code 12 vs 34).
# Dropping it would collapse genuinely different failures onto one signature and
# suppress the required re-alert (Greptile P1). So we strip only clearly volatile
# numbers — long digit runs (timestamps, epochs, pids, ports, request ids) — and
# any number glued to letters (``req42``, ``id7f`` fragments) while KEEPING short
# standalone codes (1–3 digits) that carry meaning.
_HEX_RE = re.compile(r"0x[0-9a-fA-F]+|\b[0-9a-fA-F]{8,}\b")
# Digits fused to an adjacent letter/underscore (e.g. "worker3", "id42") are ids,
# not categories — drop the whole alnum run so it can't leak a volatile number.
_ALNUM_ID_RE = re.compile(r"\b(?=\w*\d)(?=\w*[a-z])\w+\b")
# Long standalone digit runs (>= 4) are volatile (timestamps, epochs, pids); a
# short run (1–3 digits) is a meaningful code and is preserved verbatim.
_LONG_NUM_RE = re.compile(r"\b\d{4,}\b")
_WS_RE = re.compile(r"\s+")
_SIGNATURE_PREFIX_CHARS = 200
def normalise_error(error: str) -> str:
"""Return a stable, comparable form of an error message.
Lower-cases and strips *volatile* tokens — hex ids / UUID-ish blobs, long
digit runs (timestamps, epochs, pids, request ids) and numbers fused into
identifier-like words — then collapses whitespace and keeps a bounded
prefix. Short standalone numeric codes (HTTP status, error/exit codes) are
**preserved** so genuinely different failures (``404`` vs ``500``) keep
distinct signatures and re-alert, while a changing request id does not mint
a new incident every retry.
"""
text = (error or "").strip().lower()
text = _HEX_RE.sub("", text)
text = _ALNUM_ID_RE.sub("", text)
text = _LONG_NUM_RE.sub("", text)
text = _WS_RE.sub(" ", text).strip()
return text[:_SIGNATURE_PREFIX_CHARS]
def error_signature(error: str) -> str:
"""Return a stable ``sha256`` signature for an error message.
Failures sharing a :func:`normalise_error` form share a signature, so the
tracker groups them into one incident and alerts once; a genuinely
different error yields a different signature and re-alerts.
"""
return hashlib.sha256(normalise_error(error).encode("utf-8")).hexdigest()
@dataclass
class Incident:
"""A durable grouping of repeated failures for one job + error signature.
Lifecycle: ``detected`` (a failure seen, threshold not yet met) →
``alerted`` (the operator has been told once) → ``resolved`` (a later
success closed it). A changed error signature is a *new* incident.
Attributes:
job_id: ID of the failing job.
signature: Stable :func:`error_signature` grouping the failures.
state: Lifecycle state (``detected`` / ``alerted`` / ``resolved``).
error: The most recent raw error message (for display in the alert).
first_seen: Epoch seconds of the first failure in this incident.
last_seen: Epoch seconds of the most recent failure.
count: Number of failing ticks grouped into this incident.
job_name: Human-readable job name for display.
"""
job_id: str
signature: str
state: Literal["detected", "alerted", "resolved"] = "detected"
error: Optional[str] = None
first_seen: float = field(default_factory=time.time)
last_seen: float = field(default_factory=time.time)
count: int = 0
job_name: str = ""
def to_dict(self) -> Dict[str, Any]:
return {
"job_id": self.job_id,
"signature": self.signature,
"state": self.state,
"error": self.error,
"first_seen": self.first_seen,
"last_seen": self.last_seen,
"count": self.count,
"job_name": self.job_name,
}
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "Incident":
return cls(
job_id=d.get("job_id", ""),
signature=d.get("signature", ""),
state=d.get("state", "detected"),
error=d.get("error"),
first_seen=d.get("first_seen", time.time()),
last_seen=d.get("last_seen", time.time()),
count=d.get("count", 0),
job_name=d.get("job_name", ""),
)
class IncidentTracker:
"""Pure latching policy that turns a stream of runs into de-duped alerts.
Feed each :class:`RunRecord` to :meth:`observe`. It returns an
:class:`Incident` **only when an operator alert is due** — the first time a
failure signature crosses the threshold — and ``None`` on every subsequent
identical failure (already alerted) or on a plain success with no open
incident. A success that closes an open incident returns the now-``resolved``
incident once (a single recovery note); a *different* error mints and alerts
a new incident.
State is a small per-job dict, so it round-trips through any
:class:`JobStateStoreProtocol` the wrapper already provides — no new
persistence surface. Construct one tracker per policy and reuse it; the
per-job open-incident is carried in the ``state`` you pass back in.
Args:
after_failures: Alert only after this many *consecutive* failures of
the same signature (default 1 = alert on the first failure). Values
below 1 are treated as 1.
"""
def __init__(self, after_failures: int = 1):
self.after_failures = max(int(after_failures), 1)
def observe(
self,
record: Any,
state: Optional[Dict[str, Any]] = None,
) -> Optional[Incident]:
"""Fold one run into the incident state; return an alert-due incident.
Args:
record: A :class:`RunRecord` (or any object exposing ``status``,
``error``, ``job_id``, ``job_name``, ``timestamp``).
state: The per-job incident state carried between observations
(mutated in place). Pass the same dict back each tick — e.g.
loaded from / saved to a :class:`JobStateStoreProtocol`.
Returns:
An :class:`Incident` when the operator should be alerted (a newly
latched failure) or notified of a recovery (a just-``resolved``
incident), otherwise ``None``.
"""
if state is None:
state = {}
status = getattr(record, "status", None)
if status == "failed":
return self._observe_failure(record, state)
if status == "succeeded":
return self._observe_success(record, state)
# ``skipped`` / ``no_change`` are neither failure nor recovery: they do
# not open, advance, or close an incident.
return None
# ── internals ────────────────────────────────────────────────────
def _observe_failure(
self, record: Any, state: Dict[str, Any]
) -> Optional[Incident]:
error = getattr(record, "error", None) or ""
signature = error_signature(error)
ts = getattr(record, "timestamp", None) or time.time()
job_id = getattr(record, "job_id", "") or ""
job_name = getattr(record, "job_name", "") or ""
open_inc = state.get("incident")
if open_inc and open_inc.get("signature") == signature and open_inc.get("state") != "resolved":
# Same ongoing failure: advance counters; alert again only if the
# threshold is only now crossed and we have not alerted yet.
open_inc["count"] = open_inc.get("count", 0) + 1
open_inc["last_seen"] = ts
open_inc["error"] = error
state["consecutive"] = state.get("consecutive", 0) + 1
if (
open_inc.get("state") == "detected"
and state["consecutive"] >= self.after_failures
):
open_inc["state"] = "alerted"
return Incident.from_dict(open_inc)
return None
# A new (or changed) failure signature mints a fresh incident. Any
# prior open incident is implicitly superseded (a different error).
incident = Incident(
job_id=job_id,
signature=signature,
state="detected",
error=error,
first_seen=ts,
last_seen=ts,
count=1,
job_name=job_name,
)
state["incident"] = incident.to_dict()
state["consecutive"] = 1
if self.after_failures <= 1:
incident.state = "alerted"
state["incident"]["state"] = "alerted"
return incident
return None
def _observe_success(
self, record: Any, state: Dict[str, Any]
) -> Optional[Incident]:
state["consecutive"] = 0
open_inc = state.get("incident")
if not open_inc or open_inc.get("state") == "resolved":
return None
ts = getattr(record, "timestamp", None) or time.time()
was_alerted = open_inc.get("state") == "alerted"
open_inc["state"] = "resolved"
open_inc["last_seen"] = ts
resolved = Incident.from_dict(open_inc)
# Clear the open incident so a later failure of the same signature
# correctly mints and alerts a fresh incident.
state.pop("incident", None)
# Only emit a recovery note if the operator was actually alerted — a
# sub-threshold blip that never alerted should not send "recovered".
if was_alerted:
return resolved
return None
@@ -117,6 +117,47 @@ class ScheduleLoop:
self._thread.start()
logger.info("ScheduleLoop started (tick=%ss)", self._tick)
def run_forever(
self,
*,
poll_seconds: Optional[float] = None,
owner_id: Optional[str] = None,
) -> None:
"""Block the calling thread, claiming and firing due jobs until interrupted.
This is the *foreground* counterpart to :meth:`start` (which runs the
same tick body on a background daemon thread). It lets a standalone CLI
daemon host the store poller without a gateway/UI host, so a job added
via ``schedule add`` actually fires. Firing goes through the same
lease-based :meth:`fire_due`, so a foreground poller cannot double-fire
alongside a gateway (at-most-once ``claim_due_jobs``).
Returns on ``KeyboardInterrupt`` (Ctrl-C) so a CLI can exit cleanly.
Args:
poll_seconds: Seconds between polls; defaults to the loop's
``tick_seconds``.
owner_id: Override the per-process claim identity (host:pid:uuid by
default).
"""
if poll_seconds is not None:
self._tick = poll_seconds
if owner_id is not None:
self._owner_id = owner_id
self._stop_event.clear()
logger.info("ScheduleLoop.run_forever started (poll=%ss)", self._tick)
try:
while not self._stop_event.is_set():
try:
self.fire_due()
except Exception:
logger.exception("ScheduleLoop tick error")
self._stop_event.wait(timeout=self._tick)
except KeyboardInterrupt:
logger.info("ScheduleLoop.run_forever interrupted")
finally:
self._stop_event.set()
def stop(self, timeout: float = 5.0) -> None:
"""Signal the loop to stop and wait for the thread to exit.
@@ -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
@@ -242,7 +242,8 @@ class SessionData:
}
if self.last_compaction is not None:
data["last_compaction"] = self.last_compaction.to_dict()
for key in ("model", "llm", "total_tokens", "token_count", "cost", "source"):
for key in ("model", "llm", "total_tokens", "token_count", "cost", "source",
"reasoning_effort"):
if key in self.metadata:
data[key] = self.metadata[key]
return data
@@ -270,7 +271,8 @@ class SessionData:
# resume can recover the recorded model instead of silently reverting to
# the current default (Issue #3685). Existing metadata always wins.
metadata = dict(data.get("metadata") or {})
for key in ("model", "llm", "total_tokens", "token_count", "cost", "source"):
for key in ("model", "llm", "total_tokens", "token_count", "cost", "source",
"reasoning_effort"):
if key not in metadata and data.get(key) is not None:
metadata[key] = data[key]
return cls(
@@ -1449,6 +1451,28 @@ class DefaultSessionStore:
return recorded
return None
def get_session_reasoning_effort(self, session_id: str) -> Optional[str]:
"""Return the reasoning effort a session was last run with (Issue #4452).
Mirrors :meth:`get_session_model`: resolves the session-level
``reasoning_effort`` recorded in metadata so a resume (``--continue`` /
``--session``) can restore the graded effort alongside the model. Returns
``None`` when none was recorded, so a caller falls back to the default /
per-invocation value.
"""
try:
session = self._read_session_fresh(session_id)
except Exception:
return None
effort = session.metadata.get("reasoning_effort")
if isinstance(effort, str) and effort:
return effort
for message in reversed(session.messages):
recorded = (message.metadata or {}).get("reasoning_effort")
if isinstance(recorded, str) and recorded:
return recorded
return None
def set_agent_info(
self,
session_id: str,
@@ -23,6 +23,7 @@ __all__ = [
"SkillProperties",
"SkillMetadata",
"SkillRequirements",
"SkillAutomation",
"SkillState",
"ParseError",
"ValidationError",
@@ -81,6 +82,7 @@ _LAZY_GROUPS = {
'SkillProperties': ('praisonaiagents.skills.models', 'SkillProperties'),
'SkillMetadata': ('praisonaiagents.skills.models', 'SkillMetadata'),
'SkillRequirements': ('praisonaiagents.skills.models', 'SkillRequirements'),
'SkillAutomation': ('praisonaiagents.skills.models', 'SkillAutomation'),
'SkillState': ('praisonaiagents.skills.models', 'SkillState'),
'ParseError': ('praisonaiagents.skills.models', 'ParseError'),
'ValidationError': ('praisonaiagents.skills.models', 'ValidationError'),
@@ -142,6 +142,73 @@ class SkillRequirements:
return not self.is_empty()
@dataclass
class SkillAutomation:
"""Optional proactive-automation descriptor parsed from a skill's frontmatter.
This is a protocol-only contract (no heavy imports): it carries just enough
for a wrapper consumer (the scheduler's consent-first ``SuggestionEngine`` and
``DeliveryRouter``) to propose and, on user consent, materialise a scheduled,
self-delivering job. Nothing here runs a job or delivers a result — it is the
bridge data that lets an installed skill say "run me on this schedule".
Parsed from ``metadata.praisonai.automation`` so older parsers ignore it.
Attributes:
schedule: Schedule expression, e.g. "cron:0 8 * * mon-fri", "daily",
"*/30m", "at:<iso>" (required).
deliver: Delivery target token, e.g. "origin", "<platform>",
"<platform>:<chat>[:<thread>]", "all" (optional).
prompt: Task the scheduled run should perform (optional).
"""
schedule: str
deliver: Optional[str] = None
prompt: Optional[str] = None
@classmethod
def from_metadata(cls, metadata: dict) -> Optional["SkillAutomation"]:
"""Parse an automation block from a skill's ``metadata`` mapping.
Looks up ``metadata['praisonai']['automation']``. Returns None when the
block is absent or malformed (missing/empty ``schedule``), so skills
without automation simply parse to ``automation=None``.
Args:
metadata: The parsed ``metadata`` bag from SKILL.md frontmatter.
Returns:
SkillAutomation instance, or None when no valid block is present.
"""
if not isinstance(metadata, dict):
return None
praisonai = metadata.get("praisonai")
if not isinstance(praisonai, dict):
return None
automation = praisonai.get("automation")
if not isinstance(automation, dict):
return None
schedule = automation.get("schedule")
if not isinstance(schedule, str) or not schedule.strip():
return None
deliver = automation.get("deliver")
prompt = automation.get("prompt")
return cls(
schedule=schedule.strip(),
deliver=str(deliver).strip() if deliver is not None else None,
prompt=str(prompt) if prompt is not None else None,
)
def to_dict(self) -> dict:
"""Convert to dictionary, excluding None values."""
result = {"schedule": self.schedule}
if self.deliver is not None:
result["deliver"] = self.deliver
if self.prompt is not None:
result["prompt"] = self.prompt
return result
@dataclass
class SkillProperties:
"""Properties parsed from a skill's SKILL.md frontmatter.
@@ -178,6 +245,10 @@ class SkillProperties:
shell: Optional[str] = None # "bash" | "powershell"
# Capability requirements (new)
requirements: Optional[SkillRequirements] = None
# Optional proactive-automation descriptor (parsed from
# metadata.praisonai.automation). Protocol-only bridge data consumed by the
# wrapper scheduler's consent-first SuggestionEngine + DeliveryRouter.
automation: Optional[SkillAutomation] = None
# Provenance + usage telemetry (read by lifecycle/curator plugins).
# These are protocol-only data fields; retention policy lives in plugins.
agent_created: bool = False
@@ -219,6 +290,8 @@ class SkillProperties:
result["paths"] = self.paths
if self.shell is not None:
result["shell"] = self.shell
if self.automation is not None:
result["automation"] = self.automation.to_dict()
if self.agent_created:
result["agent-created"] = True
if self.created_at is not None:
@@ -3,7 +3,13 @@
from pathlib import Path
from typing import Optional, Tuple
from .models import ParseError, ValidationError, SkillProperties, SkillRequirements
from .models import (
ParseError,
ValidationError,
SkillProperties,
SkillRequirements,
SkillAutomation,
)
def find_skill_md(skill_dir: Path) -> Optional[Path]:
@@ -54,9 +60,14 @@ def parse_frontmatter(content: str) -> Tuple[dict, str]:
if not isinstance(metadata, dict):
raise ParseError("SKILL.md frontmatter must be a YAML mapping")
# Convert metadata values to strings where needed
# Convert scalar metadata values to strings where needed, but preserve
# nested mappings (e.g. the namespaced ``praisonai`` bag carrying an
# optional automation block) so structured extensions survive parsing.
if "metadata" in metadata and isinstance(metadata["metadata"], dict):
metadata["metadata"] = {str(k): str(v) for k, v in metadata["metadata"].items()}
metadata["metadata"] = {
str(k): v if isinstance(v, dict) else str(v)
for k, v in metadata["metadata"].items()
}
return metadata, body
@@ -156,6 +167,9 @@ def read_properties(skill_dir: Path) -> SkillProperties:
shell=metadata.get("shell"),
# Parse capability requirements
requirements=SkillRequirements.from_frontmatter(metadata),
# Parse optional proactive-automation descriptor from the namespaced
# metadata.praisonai.automation block (None when absent/malformed).
automation=SkillAutomation.from_metadata(metadata.get("metadata") or {}),
# Provenance + usage telemetry (accept hyphen or underscore forms)
agent_created=_coerce_bool(
metadata.get("agent-created", metadata.get("agent_created")), False
@@ -694,10 +694,15 @@ class Task:
# Store task output in memory
try:
logger.info(f"Task {self.id}: Storing task output in memory...")
self.store_in_memory(
content=task_output.raw,
agent_name=self.agent.name if self.agent else "Agent",
task_id=self.id
# store_in_memory -> Memory.store_long_term is synchronous
# (embedding-API call + DB write). Offload to a thread so it
# never blocks the event loop for tasks batched into the same
# asyncio.gather(...) parallel fan-out.
await asyncio.to_thread(
self.store_in_memory,
task_output.raw,
self.agent.name if self.agent else "Agent",
self.id
)
logger.info(f"Task {self.id}: Task output stored in memory")
except Exception as e:
@@ -26,11 +26,11 @@ Usage:
# Compute an adaptive token budget for a given task complexity (0.0-1.0)
tokens = budget.get_tokens_for_complexity(0.8)
# Note: `agent.thinking_budget` stores this object on the agent as a hint,
# but the core Agent request pipeline does not yet apply it automatically.
# To influence step-by-step reasoning today, use the built-in
# `reasoning_steps` option instead:
agent = Agent(instructions="...", reasoning_steps=True)
# `agent.thinking_budget` is a backward-compatible alias for the unified
# `reasoning_effort` control. Prefer the graded level directly, which the
# core request pipeline translates to each provider's native parameter
# (OpenAI/xAI `reasoning_effort`, Anthropic/Gemini extended-thinking budget):
agent = Agent(instructions="...", reasoning_effort="high")
"""
__all__ = [
@@ -40,6 +40,10 @@ __all__ = [
# Tracking
"ThinkingUsage",
"ThinkingTracker",
# Reasoning-effort translation (provider-portable)
"resolve_reasoning_params",
"normalize_effort",
"EFFORT_LEVELS",
]
@@ -60,5 +64,9 @@ def __getattr__(name: str):
if name == "ThinkingTracker":
from .tracker import ThinkingTracker
return ThinkingTracker
if name in ("resolve_reasoning_params", "normalize_effort", "EFFORT_LEVELS"):
from . import effort
return getattr(effort, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,126 @@
"""
Reasoning-effort translation for PraisonAI Agents.
A single, graded, provider-portable reasoning-effort control that resolves to
each provider's native request parameter:
- OpenAI o-series / GPT-5 / xAI reasoning models -> native ``reasoning_effort``
(``minimal|low|medium|high``).
- Anthropic / Gemini extended-thinking models -> a ``thinking`` token budget.
- Models without a reasoning control -> nothing (silently ignored,
backward-compatible).
Zero overhead when unused: ``off``/``None`` resolves to an empty dict and the
helper is only imported on the request path when an effort is actually set.
"""
from typing import Any, Dict, Optional
# Canonical graded levels shared with the CLI surface
# (``praisonai_code.cli.features.thinking.THINKING_LEVELS``).
EFFORT_LEVELS = ("off", "minimal", "low", "medium", "high")
# Native ``reasoning_effort`` only accepts these; ``off`` is a no-op.
_NATIVE_EFFORT = {"minimal", "low", "medium", "high"}
# Extended-thinking token budgets for Anthropic/Gemini, mirroring the CLI's
# ``THINKING_BUDGET_MAP`` so a level means the same thing on every surface.
_EFFORT_BUDGET_MAP: Dict[str, Optional[int]] = {
"off": None,
"minimal": 2000,
"low": 4000,
"medium": 8000,
"high": 16000,
}
# Inverse of the budget map: lets a legacy ``thinking_budget`` int be normalised
# back to the nearest graded level so both surfaces share one internal value.
_BUDGET_EFFORT_PAIRS = sorted(
((tokens, level) for level, tokens in _EFFORT_BUDGET_MAP.items() if tokens),
key=lambda pair: pair[0],
)
def normalize_effort(value: Any) -> Optional[str]:
"""Normalise a reasoning-effort value to a canonical level or ``None``.
Accepts a graded string (``off|minimal|low|medium|high``, case-insensitive)
or a legacy ``thinking_budget`` int (mapped to the nearest level). Unknown
or unset values return ``None`` (treated as "no reasoning control").
"""
if value is None:
return None
if isinstance(value, bool):
# Guard against ``True``/``False`` sneaking in via ``int`` handling.
return "medium" if value else None
if isinstance(value, str):
level = value.strip().lower()
return level if level in EFFORT_LEVELS else None
if isinstance(value, int):
if value <= 0:
return None
# Map a token budget to the smallest level whose budget covers it.
for tokens, level in _BUDGET_EFFORT_PAIRS:
if value <= tokens:
return level
return "high"
return None
def _is_native_effort_model(model: str) -> bool:
"""OpenAI o-series / GPT-5 / xAI reasoning models take native effort."""
from ..llm.model_capabilities import is_reasoning_model
name = (model or "").lower()
if name.startswith("xai/") or "grok" in name:
return True
return is_reasoning_model(model)
def _is_extended_thinking_model(model: str) -> bool:
"""Anthropic / Gemini models expose an extended-thinking token budget."""
name = (model or "").lower()
return (
"claude" in name
or "anthropic" in name
or "gemini" in name
)
def resolve_reasoning_params(effort: Any, model: str) -> Dict[str, Any]:
"""Translate a unified reasoning-effort level to provider-native kwargs.
Args:
effort: A graded level (``off|minimal|low|medium|high``) or a legacy
``thinking_budget`` int; anything else is treated as unset.
model: The target model name (with or without provider prefix).
Returns:
A dict of native request params to merge into the completion call:
``{"reasoning_effort": <level>}`` for OpenAI/xAI reasoning models,
``{"thinking": {"type": "enabled", "budget_tokens": <int>}}`` for
Anthropic/Gemini extended-thinking models, or ``{}`` when the effort is
off/unset or the model has no reasoning control.
"""
level = normalize_effort(effort)
if level is None or level == "off":
return {}
# Anthropic / Gemini expose an extended-thinking token budget. Checked first
# because these families can also match the generic reasoning-model
# classifier, but their native control is the thinking budget, not
# ``reasoning_effort``.
if _is_extended_thinking_model(model):
budget = _EFFORT_BUDGET_MAP.get(level)
if budget:
return {"thinking": {"type": "enabled", "budget_tokens": budget}}
return {}
# OpenAI o-series / GPT-5 / xAI reasoning models take native reasoning_effort.
if _is_native_effort_model(model):
if level in _NATIVE_EFFORT:
return {"reasoning_effort": level}
return {}
# No known reasoning control for this model: silently ignore.
return {}
@@ -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.1"
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]
@@ -0,0 +1,120 @@
"""Tests for MCP stdio fail-closed behaviour (issue #4375).
A stdio MCP server that times out, errors on init, or lists zero tools must
raise instead of returning a usable-but-empty MCP object. Otherwise
``Agent(tools=mcp)`` silently degrades into a tool-less chat and the model
hallucinates answers with ``n_tools == 0``.
"""
import threading
from unittest.mock import patch
import pytest
from praisonaiagents.mcp import mcp as mcp_module
from praisonaiagents.mcp.mcp import MCP
class _FakeRunner:
"""Stand-in for MCPToolRunner that skips the real stdio subprocess."""
def __init__(self, server_params, timeout=60, *, tools=None,
init_ok=True, init_error=None):
self.server_params = server_params
self.timeout = timeout
self.tools = tools if tools is not None else []
self.resources = []
self.resource_templates = []
self.prompts = []
self._init_error = init_error
self.initialized = threading.Event()
if init_ok:
self.initialized.set()
def shutdown(self):
pass
def stop(self):
pass
def _make_runner_factory(**runner_kwargs):
def factory(server_params, timeout=60):
return _FakeRunner(server_params, timeout, **runner_kwargs)
return factory
@pytest.mark.skipif(not mcp_module.MCP_AVAILABLE, reason="mcp package not installed")
def test_mcp_empty_tools_raises():
"""A server that lists zero tools raises RuntimeError, not [] ."""
with patch.object(mcp_module, "MCPToolRunner", _make_runner_factory(tools=[])):
with pytest.raises(RuntimeError, match="produced 0 tools"):
MCP("/usr/bin/python fake_server.py", timeout=5)
@pytest.mark.skipif(not mcp_module.MCP_AVAILABLE, reason="mcp package not installed")
def test_mcp_timeout_raises():
"""An init timeout raises TimeoutError instead of printing a warning."""
factory = _make_runner_factory(init_ok=False)
with patch.object(mcp_module, "MCPToolRunner", factory):
with pytest.raises(TimeoutError, match="timed out"):
MCP("/usr/bin/python fake_server.py", timeout=1)
@pytest.mark.skipif(not mcp_module.MCP_AVAILABLE, reason="mcp package not installed")
def test_mcp_init_error_raises():
"""An init error surfaced by the runner raises RuntimeError."""
factory = _make_runner_factory(init_error="boom while starting server")
with patch.object(mcp_module, "MCPToolRunner", factory):
with pytest.raises(RuntimeError, match="initialization failed"):
MCP("/usr/bin/python fake_server.py", timeout=5)
@pytest.mark.skipif(not mcp_module.MCP_AVAILABLE, reason="mcp package not installed")
def test_mcp_nonempty_tools_ok():
"""A server that lists at least one tool constructs successfully."""
class _FakeTool:
name = "get_current_time"
description = "Return the current time"
inputSchema = {"type": "object", "properties": {}, "required": []}
factory = _make_runner_factory(tools=[_FakeTool()])
with patch.object(mcp_module, "MCPToolRunner", factory):
mcp = MCP("/usr/bin/python fake_server.py", timeout=5)
names = [getattr(t, "__name__", None) for t in mcp]
assert "get_current_time" in names
assert len(list(mcp)) == len(mcp.get_tools())
@pytest.mark.skipif(not mcp_module.MCP_AVAILABLE, reason="mcp package not installed")
def test_mcp_empty_server_with_filter_still_raises():
"""A filter must not mask a server that advertised zero tools.
When the server itself lists no tools, the filter removed nothing, so the
fail-closed error must still fire even though allowed_tools was supplied
(issue #4375).
"""
factory = _make_runner_factory(tools=[])
with patch.object(mcp_module, "MCPToolRunner", factory):
with pytest.raises(RuntimeError, match="produced 0 tools"):
MCP("/usr/bin/python fake_server.py", timeout=5,
allowed_tools=["get_current_time"])
@pytest.mark.skipif(not mcp_module.MCP_AVAILABLE, reason="mcp package not installed")
def test_mcp_filter_removes_all_tools_does_not_raise():
"""A filter that deliberately removes every tool is a valid empty config."""
class _FakeTool:
name = "get_current_time"
description = "Return the current time"
inputSchema = {"type": "object", "properties": {}, "required": []}
factory = _make_runner_factory(tools=[_FakeTool()])
with patch.object(mcp_module, "MCPToolRunner", factory):
mcp = MCP("/usr/bin/python fake_server.py", timeout=5,
disabled_tools=["get_current_time"])
assert list(mcp) == []
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+91 -2
View File
@@ -2,7 +2,11 @@
import asyncio
from praisonaiagents.agent.run_outcome import RunOutcome
from praisonaiagents.agent.run_outcome import (
RunOutcome,
classify_finish_reason,
PROVIDER_BLOCK_REASONS,
)
from praisonaiagents.agent.execution_mixin import ExecutionMixin
@@ -59,8 +63,9 @@ class _FakeAgent(ExecutionMixin):
autonomy_enabled = False
stream = None
def __init__(self, behavior):
def __init__(self, behavior, stop_reason="completed"):
self.behavior = behavior
self.last_stop_reason = stop_reason
def _load_history_context(self):
pass
@@ -71,6 +76,8 @@ class _FakeAgent(ExecutionMixin):
def chat(self, prompt, **kwargs):
if self.behavior == "ok":
return "answer"
if self.behavior == "empty":
return ""
raise ValueError("kaboom")
async def achat(self, prompt, **kwargs):
@@ -146,3 +153,85 @@ def test_astart_external_cancellation_propagates():
pass
else:
raise AssertionError("external cancellation should propagate")
# --- Provider finish_reason classification (content-filter/refusal/length) ---
def test_classify_finish_reason_normal_stops_are_none():
assert classify_finish_reason(None) is None
assert classify_finish_reason("stop") is None
assert classify_finish_reason("tool_calls") is None
assert classify_finish_reason("function_call") is None
# Unknown/absent finish reasons behave exactly as today.
assert classify_finish_reason("some_new_reason") is None
def test_classify_finish_reason_blocks():
assert classify_finish_reason("content_filter") == "content_filtered"
assert classify_finish_reason("CONTENT_FILTER") == "content_filtered"
assert classify_finish_reason("length") == "length_truncated"
assert classify_finish_reason("max_tokens") == "length_truncated"
# A safety refusal is carried independently of finish_reason.
assert classify_finish_reason("stop", refusal="I can't help with that") == "refused"
def test_provider_block_reasons_outrank_failed_but_not_cancel():
from praisonaiagents.agent.run_outcome import _REASON_PRECEDENCE
for reason in PROVIDER_BLOCK_REASONS:
assert _REASON_PRECEDENCE[reason] > _REASON_PRECEDENCE["failed"]
assert _REASON_PRECEDENCE[reason] < _REASON_PRECEDENCE["cancelled"]
assert _REASON_PRECEDENCE[reason] < _REASON_PRECEDENCE["hard_timeout"]
def test_run_outcome_surfaces_provider_block_over_empty_completed():
# An empty result from a content-filtered turn must surface the specific,
# actionable reason instead of a silent empty "completed".
o = _FakeAgent("empty", stop_reason="content_filtered").run(
"hi", return_outcome=True
)
assert o.reason == "content_filtered"
assert o.succeeded is False
def test_run_outcome_completed_when_no_block():
o = _FakeAgent("ok", stop_reason="completed").run("hi", return_outcome=True)
assert o.reason == "completed" and o.output == "answer"
def test_astart_outcome_surfaces_refusal():
o = asyncio.run(
_FakeAgent("ok", stop_reason="refused").astart("hi", return_outcome=True)
)
assert o.reason == "refused"
assert o.succeeded is False
def test_agent_last_stop_reason_prefers_own_block_over_stale_backend():
# Regression: an agent-owned provider block (OpenAI-native classification)
# must win over a stale backend reason like "max_steps"; otherwise the
# actionable "refused" is masked and _outcome_for_result reports success.
from praisonaiagents.agent.agent import Agent
agent = Agent(instructions="test", llm="gpt-4o-mini")
agent._last_stop_reason = "refused"
class _StaleBackend:
_last_stop_reason = "max_steps"
agent.llm_instance = _StaleBackend()
assert agent.last_stop_reason == "refused"
def test_agent_last_stop_reason_backend_used_when_own_completed():
# When the agent recorded nothing specific, the backend reason still wins.
from praisonaiagents.agent.agent import Agent
agent = Agent(instructions="test", llm="gpt-4o-mini")
agent._last_stop_reason = "completed"
class _Backend:
_last_stop_reason = "max_steps"
agent.llm_instance = _Backend()
assert agent.last_stop_reason == "max_steps"
@@ -73,8 +73,8 @@ class TestVideoAgentInit:
"""Test initialization with llm parameter."""
from praisonaiagents import VideoAgent
agent = VideoAgent(llm="gemini/veo-3.0-generate-preview")
assert agent.llm == "gemini/veo-3.0-generate-preview"
agent = VideoAgent(llm="gemini/veo-3.1-generate-preview")
assert agent.llm == "gemini/veo-3.1-generate-preview"
def test_with_model_alias(self):
"""Test initialization with model= alias for llm=."""
@@ -336,14 +336,14 @@ class TestVideoAgentProviders:
"""Test Gemini Veo configuration."""
from praisonaiagents import VideoAgent
agent = VideoAgent(llm="gemini/veo-3.0-generate-preview")
agent = VideoAgent(llm="gemini/veo-3.1-generate-preview")
assert "veo" in agent.llm
def test_vertex_ai_provider(self):
"""Test Vertex AI configuration."""
from praisonaiagents import VideoAgent
agent = VideoAgent(llm="vertex_ai/veo-3.0-generate-preview")
agent = VideoAgent(llm="vertex_ai/veo-3.1-generate-001")
assert "vertex_ai" in agent.llm
def test_runwayml_provider(self):
@@ -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.
@@ -457,6 +457,43 @@ class TestRegistryScopeBridge:
)
class TestContextCacheScopeId:
"""The in-context ``is_already_approved`` cache must be keyed per-instance
(``scope_id``) so a grant marked on one worker cannot pre-authorize a
distinct same-named worker sharing the execution context — the fast-path
checked *before* the per-instance session lookup.
"""
def test_context_cache_isolated_by_scope_id(self):
from praisonaiagents.approval.registry import ApprovalRegistry
registry = ApprovalRegistry()
args = {"command": "git status"}
# Two workers share the display name "Agent" but have distinct scope ids.
registry.mark_approved("execute_command", args, "Agent", scope_id="inst-A")
# Same instance sees its own grant.
assert registry.is_already_approved(
"execute_command", args, "Agent", scope_id="inst-A"
)
# A distinct same-named instance must NOT inherit it.
assert not registry.is_already_approved(
"execute_command", args, "Agent", scope_id="inst-B"
)
def test_name_keyed_fallback_preserved(self):
# With no scope_id supplied, behaviour falls back to name-keyed caching
# (backward compatible).
from praisonaiagents.approval.registry import ApprovalRegistry
registry = ApprovalRegistry()
args = {"command": "git status"}
registry.mark_approved("execute_command", args, "worker")
assert registry.is_already_approved("execute_command", args, "worker")
assert not registry.is_already_approved("execute_command", args, "other")
# ── @require_approval positional-argument scoping ───────────────────────────
@@ -0,0 +1,241 @@
"""
Tests for the durable, queryable per-session event log (SqliteEventLog).
Covers: append+query with a per-session cursor, restart durability, best-effort
append (never raises), prune by age/count, and bus.attach_sink wiring including
the no-subscriber fast path.
"""
import time
import pytest
from praisonaiagents.bus import (
Event,
EventBus,
EventType,
SqliteEventLog,
EventLogProtocol,
)
@pytest.fixture
def db_path(tmp_path):
return str(tmp_path / "events.sqlite")
class TestSqliteEventLog:
def test_protocol_conformance(self, db_path):
log = SqliteEventLog(db_path=db_path)
assert isinstance(log, EventLogProtocol)
log.close()
def test_append_and_query_ordered(self, db_path):
log = SqliteEventLog(db_path=db_path)
for i in range(3):
log.append(
Event(
type=EventType.MESSAGE_CREATED.value,
data={"session_id": "s1", "i": i},
)
)
events = log.query("s1")
assert [e.data["i"] for e in events] == [0, 1, 2]
assert [e.metadata["seq"] for e in events] == [1, 2, 3]
log.close()
def test_cursor_after_seq(self, db_path):
log = SqliteEventLog(db_path=db_path)
for i in range(5):
log.append(Event(type="custom", data={"session_id": "s1", "i": i}))
tail = log.query("s1", after_seq=2)
assert [e.metadata["seq"] for e in tail] == [3, 4, 5]
log.close()
def test_per_session_isolation(self, db_path):
log = SqliteEventLog(db_path=db_path)
log.append(Event(type="custom", data={"session_id": "a"}))
log.append(Event(type="custom", data={"session_id": "b"}))
log.append(Event(type="custom", data={"session_id": "a"}))
assert len(log.query("a")) == 2
assert len(log.query("b")) == 1
# each session has its own monotonic seq starting at 1
assert [e.metadata["seq"] for e in log.query("a")] == [1, 2]
assert [e.metadata["seq"] for e in log.query("b")] == [1]
log.close()
def test_source_used_as_session_fallback(self, db_path):
log = SqliteEventLog(db_path=db_path)
log.append(Event(type=EventType.SESSION_CREATED.value, source="sess-x"))
assert len(log.query("sess-x")) == 1
log.close()
def test_durable_across_restart(self, db_path):
log = SqliteEventLog(db_path=db_path)
log.append(Event(type="custom", data={"session_id": "s1"}))
log.close()
reopened = SqliteEventLog(db_path=db_path)
events = reopened.query("s1")
assert len(events) == 1
reopened.close()
def test_append_never_raises_on_bad_data(self, db_path):
log = SqliteEventLog(db_path=db_path)
class Unserializable:
pass
# non-JSON-native payload must not raise; it is coerced via default=str
log.append(
Event(type="custom", data={"session_id": "s1", "obj": Unserializable()})
)
assert len(log.query("s1")) == 1
log.close()
def test_prune_by_count(self, db_path):
log = SqliteEventLog(db_path=db_path)
for i in range(10):
log.append(Event(type="custom", data={"session_id": "s1", "i": i}))
removed = log.prune(older_than_days=0, max_rows=4)
assert removed == 6
remaining = log.query("s1")
assert len(remaining) == 4
log.close()
def test_prune_by_age(self, db_path):
log = SqliteEventLog(db_path=db_path)
old = Event(type="custom", data={"session_id": "s1"})
old.timestamp = time.time() - 10 * 86400
log.append(old)
log.append(Event(type="custom", data={"session_id": "s1"}))
removed = log.prune(older_than_days=1, max_rows=0)
assert removed == 1
assert len(log.query("s1")) == 1
log.close()
def test_seq_monotonic_after_full_prune(self, db_path):
# Deleting every row for a session must NOT rewind the cursor, else a
# consumer holding after_seq would silently miss the next events.
log = SqliteEventLog(db_path=db_path)
for _ in range(3):
log.append(Event(type="custom", data={"session_id": "s1"}))
assert [e.metadata["seq"] for e in log.query("s1")] == [1, 2, 3]
# Prune everything for the session.
log.prune(older_than_days=0, max_rows=0)
# Force-delete all remaining rows regardless of prune heuristics.
removed = log.prune(older_than_days=1e-9, max_rows=0) # noqa: F841
# Regardless of what prune left, the next seq must be > 3.
log.append(Event(type="custom", data={"session_id": "s1"}))
seqs = [e.metadata["seq"] for e in log.query("s1", after_seq=3)]
assert seqs == [4], seqs
log.close()
def test_metadata_round_trips(self, db_path):
log = SqliteEventLog(db_path=db_path)
log.append(
Event(
type="custom",
data={"session_id": "s1"},
metadata={"correlation_id": "abc", "trace": 7},
)
)
ev = log.query("s1")[0]
assert ev.metadata["correlation_id"] == "abc"
assert ev.metadata["trace"] == 7
# durable cursor fields are still overlaid
assert ev.metadata["seq"] == 1
assert ev.metadata["session_id"] == "s1"
log.close()
def test_migrates_legacy_schema_without_collision(self, db_path):
# A database written before event_seq/metadata_json existed must be
# migrated so the next append continues above MAX(seq) and does not
# collide on the primary key (silently dropping the event).
import sqlite3
conn = sqlite3.connect(db_path)
conn.executescript(
"CREATE TABLE events (session_id TEXT NOT NULL, seq INTEGER NOT NULL, "
"ts REAL NOT NULL, type TEXT NOT NULL, event_id TEXT, source TEXT, "
"data_json TEXT, PRIMARY KEY(session_id, seq));"
)
conn.execute(
"INSERT INTO events VALUES (?,?,?,?,?,?,?)",
("s1", 1, 1.0, "custom", "id1", None, "{}"),
)
conn.execute(
"INSERT INTO events VALUES (?,?,?,?,?,?,?)",
("s1", 2, 1.0, "custom", "id2", None, "{}"),
)
conn.commit()
conn.close()
log = SqliteEventLog(db_path=db_path)
log.append(Event(type="custom", data={"session_id": "s1"}))
seqs = [e.metadata["seq"] for e in log.query("s1")]
assert seqs == [1, 2, 3], seqs
log.close()
def test_cross_connection_seq_no_collision(self, db_path):
# Two independent instances sharing one DB file must not collide on seq.
a = SqliteEventLog(db_path=db_path)
b = SqliteEventLog(db_path=db_path)
for _ in range(5):
a.append(Event(type="custom", data={"session_id": "s1"}))
b.append(Event(type="custom", data={"session_id": "s1"}))
seqs = [e.metadata["seq"] for e in a.query("s1")]
assert seqs == list(range(1, 11)), seqs
a.close()
b.close()
class TestBusSinkWiring:
def test_attach_sink_persists_without_subscribers(self, db_path):
bus = EventBus()
log = SqliteEventLog(db_path=db_path)
bus.attach_sink(log)
# No subscribers: the fast path must still reach the durable sink.
bus.publish(EventType.SESSION_CREATED, {"session_id": "s1"})
assert len(log.query("s1")) == 1
log.close()
def test_detach_sink_stops_persistence(self, db_path):
bus = EventBus()
log = SqliteEventLog(db_path=db_path)
bus.attach_sink(log)
assert bus.has_sinks is True
bus.publish("custom", {"session_id": "s1"})
assert bus.detach_sink(log) is True
assert bus.has_sinks is False
bus.publish("custom", {"session_id": "s1"})
assert len(log.query("s1")) == 1
log.close()
def test_no_sink_preserves_fast_path(self):
bus = EventBus()
# No subscribers, no sinks: publish returns an event, stores nothing.
ev = bus.publish("custom", {"session_id": "s1"})
assert ev.type == "custom"
assert bus.get_history() == []
def test_sink_failure_never_breaks_publish(self):
bus = EventBus()
class BadSink:
def append(self, event):
raise RuntimeError("boom")
bus.attach_sink(BadSink())
# Must not raise despite the failing sink.
ev = bus.publish("custom", {"session_id": "s1"})
assert ev.type == "custom"
@pytest.mark.asyncio
async def test_async_publish_reaches_sink(self, db_path):
bus = EventBus()
log = SqliteEventLog(db_path=db_path)
bus.attach_sink(log)
await bus.publish_async(EventType.AGENT_STARTED, {"session_id": "s1"})
assert len(log.query("s1")) == 1
log.close()
Loaded 100 of 574 files, more files were not shown because too many files have changed in this diff. Show more