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]>
* 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]>
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]>
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]>
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]>
* 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]>
* 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]>
* 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]>
* 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]>
* 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]>
* 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]>
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.
* 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]>
* 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]>
* 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]>
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
_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]>
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]>
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
* 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]>
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]>
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]>
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]>
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]>
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]>
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
* 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]>
* 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]>
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]>
* 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]>
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]>
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]>
* 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]>
* 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]>
* 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]>
* 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]>
* 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]>
* 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]>
* 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]>
* 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]>
* 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]>
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]>
* 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]>
* 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]>
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]>
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]>
- 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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
* 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]>
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]>
* 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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
* 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]>
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]>
* 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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
`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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
`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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
`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]>
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]>
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]>
`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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
`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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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.
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.
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]>
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]>
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.
- 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]>
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.
- 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]>
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.
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]>
_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]>
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]>
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]>
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.
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]>
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.
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]>
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]>
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]>
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.
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]>
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]>
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]>
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]>
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]>
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]>
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]>
- 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]>
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]>
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]>
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]>
- 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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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.
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
- 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]>
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]>
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]>
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]>
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.
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]>
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]>
- 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]>
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.
- 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]>
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.
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]>
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]>
_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]>
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]>
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]>
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]>
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]>
- 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]>
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]>
- 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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
- _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]>
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]>
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]>
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]>
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.
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.
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.
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]>
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.
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]>
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
.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.
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.
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.
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.
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.
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.
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.
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.
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.
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]>
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]>
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]>
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]>
`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.
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
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]>
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]>
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]>
- 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]>
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]>
- 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]>
- 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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
- 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]>
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]>
- 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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
Canonical TS path stays src/praisonai-ts/; mirror sync direction is
monorepo to praisonai-js, not the reverse.
Co-authored-by: Cursor <[email protected]>
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]>
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]>
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]>
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]>
- 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]>
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]>
_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]>
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]>
- 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]>
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]>
- 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]>
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]>
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]>
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]>
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]>
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]>
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]>
Accept TS/JS issues on PraisonAI, route fixes to praisonai-js via Claude
STEP 3-ALT, block direct src/praisonai-ts/ PRs unless sync from praisonai-js,
and document routing in CONTRIBUTING, bug template, and SECURITY.
Co-authored-by: Cursor <[email protected]>
The version the desktop release is cut from. v4.7.1 predates the whole
Windows and Linux effort, so attaching those builds to it would have claimed
that release shipped something it did not contain.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A review of the fixes themselves, which is where new bugs get introduced.
**A stale tab really could cancel someone else's run.** The engine refuses to
stop a run other than the one asked for -- and the Stop button never sent the
id, so the guard could not fire and the three server tests covering it were
testing a request the product does not make. Reproduced end to end: tab A's
Stop cancelled the run tab B had started. The button names its run now.
**The CSP test caught none of seven CSP-breaking changes, including a straight
revert of the fix it exists to protect.** It matched a *variable* name against
an element id -- `stopBtn` against `id="stopBtn"`, which does not exist; the id
is `stop` -- so it silently compared nothing. It now resolves bindings to
elements before judging a line, and covers the four other ways the policy can
be broken: a style attribute in any casing or quoting, `setAttribute('style')`,
a `<style>` built at runtime, and `style=` concatenated into innerHTML. Ten
mutations, ten caught, including both directions of the original bug.
The rule it enforces is also broader than before. Hiding with an inline style
and revealing with a class leaves the element hidden from the second turn on,
just as the reverse leaves it hidden forever -- so for an element the markup
hides with a class, any `.style.display` assignment is a defect regardless of
the value.
**A whole test file ran nowhere.** `stream-pacing.test.ts` is TypeScript and
matched neither npm glob, so its eight tests -- including the positive control
for logic `ui/index.html` justifies by saying it was "ported verbatim from the
tested one" -- never executed. The skip-count assertion could not see it
either: nothing skips when nothing runs. That is the same failure the workflow
comment claims to prevent.
**Windows CI could not go green** on a clippy error in a `#[cfg(windows)]`
block, invisible from any other platform, and three tests errored in setUp on
`os.getpgid` -- which does not exist on Windows -- in the class that exists to
prove the Windows path works.
Secrets, three ways a report of success was untrue:
* A delete returned `or` of the two stores, so an unwritable fallback meant the
plaintext survived, `get` served it, and the user was told the key was gone.
* A key the primary could not accept went to the fallback while the primary
kept the old one -- and `get` reads the primary first, so the app went on
using the previous key after a successful save.
* `FileSecretStore._read` swallowed every error, so one unreadable read meant
"empty" and the next write destroyed the other secrets. The DPAPI store's
docstring names this exact hazard; this one had been left with it.
And: `macos-14` began deprecation on 2026-07-06 with brownouts that fail jobs
now, and it was the only Apple-silicon leg, so the arm64 DMG was on its way to
disappearing -- there is a test listing retired images now, because this is the
second runner to expire mid-branch. `x11_threads` declared dlsym's argument as
`*const i8`, which does not compile on ARM Linux where `c_char` is unsigned.
The two Tauri window configs must agree, and nothing checked. The log-tail
comment described dropping a seek-truncated first line that nothing dropped.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The CI matrix found this on its third run, and it is the same defect this
branch already fixed once on POSIX -- still fully present on Windows, reported
as a success. Every stop-related test failed there, and one of them says it
plainly:
'RAN-TO-COMPLETION' unexpectedly found in the log
: the run was reported cancelled but ran to the end anyway
_terminate_group sent CTRL_BREAK_EVENT. That event is delivered through a
console, and the trainer is spawned with CREATE_NO_WINDOW precisely so that it
has none -- so send_signal returned successfully, nothing died, and the
`except OSError: proc.terminate()` fallback never ran because there was no
error. The user pressed Stop, the UI said cancelled, and the fine-tune carried
on to completion holding the GPU.
It kills the process tree with `taskkill /T /F` now, which is what killpg
achieves on POSIX. /T is the part that matters: without it the trainer dies and
whatever it spawned keeps the card.
The test that was supposed to cover this asserted `proc.signals or
proc.terminated` -- "something was signalled". CTRL_BREAK_EVENT satisfied that
perfectly while the process ran on. This is the third time in this branch that
a test asserted an attempt rather than an effect, so it now asserts which pid
was killed, that the command carries /T and /F, that a failed taskkill still
escalates, and that a process which already exited is not killed again.
The tree kill is a module-level function so all of this runs from any platform.
The branch that only executes on the machine nobody tests on is exactly the
branch that was wrong.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
My own regression from two commits ago, found by looking rather than by a test.
Capping the run history meant making it a bounded deque, and the insertion was
left as `insert(0, run)`. On a *full* bounded deque that raises
IndexError("deque already at its maximum size") -- so the run after the cap
would not start, and every run after that would fail the same way, on an app
that is meant to stay open for days. `appendleft` evicts from the far end,
which is the oldest run, which is what the cap is for.
The tests now reach the cap rather than stopping short of it. Both things that
go wrong here only go wrong when the deque is full: the insertion raising, and
eviction taking the newest run instead of the oldest. Neither is observable
below the limit, which is why a test that stops at three runs proves nothing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Second round from the same matrix. Linux went green; Windows did not.
**The whole training route suite failed on Windows for a quoting reason.**
The test helper built `PRAISONAI_TRAIN_CMD` with `shlex.quote`, which escapes
an inner `'` as the concatenation `'"'"'`. POSIX reassembles that into one
token; Windows rules split it into seven. Every script in the suite prints
something, so every one of them contains a quote, and the trainer was launched
with a shattered command line -- the run never reached "running" and nine tests
timed out waiting for it. The body goes through a file now: a path has no
newlines and no quotes, so both rule sets agree about it. (`split_command`
itself is right; a Windows user would quote the Windows way.)
**The lockfile was not the bytes it says it writes.** `write_text` translates
"\n" to "\r\n" on Windows, so the file differed by platform. The Rust parser
trims each line and coped, but a lockfile whose bytes depend on the host is a
difference waiting to matter -- and the encode is the only conversion that
should be happening in a function documented as "always as UTF-8". It writes
bytes now.
The rest were test-only, and all the same mistake: `str(Path("/a/b"))` is
`\a\b` on Windows, and comparing a `PurePosixPath` to a `WindowsPath` fails
even when both name the same location. They compare `.as_posix()` now.
Also made the generated script filename a content hash rather than `hash()`,
which is salted per process and could have had two bodies collide on one file.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The CI added in the previous commit did its job on its first run: the engine
suite failed on Linux and Windows, and the Rust suite failed on Windows, while
all three passed on macOS -- the only platform any of this had ever run on.
Every failure is in the tests, not the product, and every one is the same
mistake in a different costume: asserting something about the *host* rather
than about the behaviour under test.
* Two Rust tests compared a whole PATH string against a hardcoded expectation
containing forward slashes. `PathBuf::join` writes a backslash when the tests
themselves run on Windows, so `C:/data/venv\Scripts` did not match
`C:/data/venv/Scripts`. The subject is the separator *between* entries, so
they now split on it and check the parts -- which is what the comment above
them always claimed they were doing.
* Four Python tests compared `str(Path(...))` against a POSIX string, which is
backslash-separated on Windows. They compare paths to paths now.
* The C-locale harness passed its statement through `python -c`. Python decodes
that argument with the filesystem encoding -- ASCII with surrogateescape
under LC_ALL=C on Linux -- so a test whose whole point was a non-ASCII path
died on unpaired surrogates before reaching the code under test. It goes
through a file now: source files are read as UTF-8 regardless of locale.
* The same harness captured the child's output with a bare `text=True`, which
decodes using the *parent's* encoding. On a Windows runner a test about UTF-8
handling was reading its own result back through cp1252 and failing on the
round trip rather than on anything the engine did.
* One test asked FileSecretStore to write to "/nonexistent/nope" and expected
failure. A Windows runner can create that at the drive root, so the store
succeeded and the assertion was backwards. It now puts a file where a
directory is needed, which fails everywhere.
None of this was reachable from a Mac, which is the entire argument for the
matrix.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
An adversarial review pointed out that nothing in .github referenced
praisonai-desktop. Every suite in it -- engine, shell and UI -- existed and was
never executed by anything except a person running it by hand.
The UI suite could not even be installed. jsdom was undeclared, so it ran only
on machines that happened to have one somewhere further up the tree, at
whatever version that happened to be. Pinning it surfaced the consequence
immediately: 16 tests fail on jsdom 25 and pass on 30. They had been passing
against a version nobody chose.
The workflow runs the engine tests on all three platforms, which is the point
of the portability suite; the Rust tests and clippy on all three; and the UI
tests on Linux. Clippy is included because it was already failing -- a
redundant closure in supervisor.rs -- which is exactly the sort of thing a gate
catches and a person does not.
The lifecycle suite is excluded by name rather than left to skip. It drives
real conversations against a live engine and a real model, neither of which
exists on a runner, so it would have skipped all thirteen of its tests and
exited 0. The remaining 128 run with the skip count asserted to be zero,
because a suite that skips everything looks exactly like a suite that passed.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Three of these are in the "user cancels" and "user watches a long run" paths,
which are the two things this feature exists to do.
**Opening the Training tab on a run older than ~20 minutes showed nothing, and
pinned a core doing it.** The SSE handler answered a gap by resyncing at
cursor=-1 -- which makes the next since() compute a gap again, because every
ring that has ever evicted satisfies `0 < oldest`. The `continue` then skipped
both the sleep and the terminal check, so it spun at ~13,000 resync frames per
second, delivered zero log events, and went on spinning after the run had
finished. A tqdm refresh emits two events, so a real fine-tune passes the 4000
-event ring in about twenty minutes. It now resumes at the oldest event still
held and falls through to deliver it.
**Pressing Stop before the trainer process existed cancelled nothing.** start()
returns as soon as the supervisor thread is created, so between the HTTP reply
and Popen returning, run._proc is None and stop() had nothing to signal. It
recorded STOPPING and _supervise then overwrote it with RUNNING: the user got
{"ok": true} and a "stopping" pill, and the fine-tune ran to completion and
reported "done" -- holding the GPU it was told to release. The state is now
settled under the run's lock, and a stop that arrived first terminates the
process on arrival.
**One trailing slash let a stale tab kill the live run.** The route was matched
by string, and rsplit("/") on "/train/stop/<stale-id>/" yields "", which is
falsy -- so the guard that exists to stop exactly this was skipped. The same
matching treated "/train/stopXXXX" as the stop route and read "?force=1" as
part of the run id, refusing a legitimate stop. The path is parsed now.
Also:
* Two runs could share a directory, overwriting each other's config.yaml and
interleaving into one train.log. Ids differing only in case are one directory
on macOS and Windows; and the generated id had one-second resolution, so two
runs started in the same second collided -- which the new check caught
immediately. Ids are settled before anything is created, so a refused run no
longer clobbers the config of the run it collided with.
* A method that could never work failed after the model download rather than
before it. GRPO needs reward functions this form cannot supply, so it is now
refused in milliseconds with a message saying to use the CLI. Column
requirements depend on the dataset and are still the trainer's to check, so
every method hint now names the columns it needs instead of leaving the user
to discover them an hour in.
* _last_meaningful_line read the whole log to keep forty lines from the end --
at the exact moment a run fails, and a tqdm bar writes a line per refresh. It
reads the last 64KB now.
* An unwritable runs directory raised PermissionError past the handler, so the
connection dropped with no response and the UI reported "could not reach the
engine", sending the user after the wrong problem.
* A malformed ?cursor= was an unhandled ValueError that dropped the connection.
* run.metrics and Trainer.history grew without limit in a process meant to stay
open for days.
Converting those two to bounded deques broke /train/runs and /train/status,
because a deque cannot be sliced -- caught by the existing route tests, which
is what they are for.
Tests: 49 + 23 + 6 Python, 134 UI. New coverage for replaying a run that has
outlived its buffer, every stop-route shape, the pending-stop window, run-id
collisions, and the method preflight. All five fixes were mutation-tested.
Two notes on the tests themselves. The pending-stop case cannot be reached
through HTTP -- two round trips are slower than a spawn -- so it is tested
where it is deterministic, by delaying the spawn. And its first version asserted
only that the final state was "cancelled", which a version that never signals
the process also satisfies while the job runs to the end; it now asserts the
process died and the script's completion marker never reached the log.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Found while fixing the same class of defect on the cross-platform branch.
Configuring a CSP makes Tauri stamp a nonce on the page's <style> tag, and a
nonce in a source list voids 'unsafe-inline' for that directive -- so every
style="" attribute in the document stops applying. The heading above the run
history would have lost its spacing the moment the two branches met, silently.
A class instead. The other inline styles on this branch are pre-existing and
are handled in the cross-platform work, along with the test that would have
caught this one.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Three ship-blockers, one security bug, one claim of mine that was simply wrong.
**The CSP I added broke the app, and I said it would not.** Configuring any CSP
makes Tauri stamp a nonce on the page's <style> tag and a hash on its inline
<script> at build time -- and a nonce or hash in a source list *voids*
'unsafe-inline' for that directive. So the moment a CSP existed, every
style="" attribute in the document stopped applying: a permanently visible red
Stop button, a raw file-input widget, and every tool-output block expanded, on
first launch. Nothing errors; it just looks broken. Those five attributes are
now classes. Setting .style from JavaScript is CSSOM and was never affected --
but `el.style.display=''` to reveal an element now restores the class default,
which is `display:none`, so those three call sites toggle the class instead.
**The Linux tray took the whole app down.** `tray::build(...)?` in setup, and
on Linux the tray goes through libappindicator, which is dlopen'd at first use
and *panics* if neither library is present -- not catchable, since we build
with panic=abort. Because it is dlopen'd there is no DT_NEEDED entry, so
dpkg-shlibdeps could not generate the dependency either: the package installed
cleanly and then did nothing when clicked. The tray is now non-fatal and the
library is declared explicitly, along with curl, which the uv bootstrap runs
and minimal images omit.
**The macOS overlay silently deleted the window geometry.** Tauri merges
tauri.macos.conf.json with RFC 7386, whose rule for arrays is replacement, not
element-wise merge. An overlay carrying only the two macOS keys replaced the
whole `windows` array: title, width, height and both minimums vanished, so the
app became an 800x600 "Tauri App" that could be dragged down to nothing. The
overlay now repeats the full object.
**Deleting an API key reported success while the key kept working.** Once a
secret had been written to both stores -- keyring unavailable, then available
again -- a delete that short-circuited on the first success left the other copy
behind, and the read path served it. The plaintext stayed on disk. A delete now
reaches every store, and a successful write clears the fallback so a superseded
key is not left readable.
Everything else, briefly:
* macos-13 was retired on 2025-12-04, so the Intel leg would never have
scheduled -- and the completeness check would then have failed every release,
forever, correctly. Now macos-15-intel.
* `cargo install tauri-cli ... || true` swallowed a real failure and the next
step died complaining about an unrelated missing command. It also behaved
differently per leg, since Windows defaults to pwsh where `true` is not a
command.
* Two spawn sites had no console suppression -- including the engine itself, so
a black console window sat beside the app for the entire session on Windows.
There is now a lint over every spawn site, which is honest about being a lint:
a Windows creation flag has no observable effect here, but "did anyone forget
to call it" is exactly what regressed.
* One undecodable byte silently ended the engine's log reader for the rest of
the session. `lines().map_while(Result::ok)` *stops* on the first
Err(InvalidData) rather than skipping it, and Python encodes redirected
streams with the locale code page on Windows. If it arrived before the port
announcement, startup failed 30 seconds later blaming a timeout.
* The engine spawn had no process group on Unix, so SIGTERM reached the engine
and nothing it had started -- with the training tab, a fine-tune would have
kept a GPU after quit.
* `XInitThreads` is now called first in main(). GTK never calls it, several
threads drive X anyway, and the failure is `_exit(1)` with no message.
* Empty environment variables were treated as set on the Rust side only, so
`XDG_DATA_HOME=""` made the shell look for the lockfile in the working
directory while the engine wrote it under the home directory -- "no lock,
spawn" on every launch.
* The uv installer's exit code was taken as proof it installed something;
`powershell -Command "irm ... | iex"` can exit 0 having done nothing.
* Three write-then-rename sites -- transcripts, MCP config, settings -- did not
use the retry helper whose own docstring names their failure case.
* A leftover temp file kept its old permissions, so a secret could be written
world-readable before the chmod that follows the rename. O_EXCL now.
* A DPAPI blob that would not decrypt read as empty and was then overwritten,
destroying every other secret in it.
* The tray used macOS template rendering and Cmd accelerators everywhere:
a black-on-black icon, and a menu advertising Win+Q, which Windows takes.
* "Copy data folder path" reproduced the default instead of asking the engine,
so it handed the user a path the app was not using whenever it was
overridden. /health now reports it.
Tests: 35 engine, 123 Rust, 141 UI. New: a CSP guard (there was none -- no test
in the suite sets a CSP header, which is why this shipped), a lossy-reader
suite, spawn-site hygiene with a positive control, and secret-deletion tests
that failed before the fix.
Two of my own new tests were wrong in the usual way. One asserted a repair
button *existed* without clicking it, so replacing its handler with an empty
function changed nothing. The other's comment-stripper only checked line
prefixes, so a continuation line inside a block comment -- this file's own
explanation of the bug -- was reported as an instance of it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The macOS release workflow shipped first. Adding the other two was not a matter
of adding runners: the bundle targets were macOS-only, so a Windows or Linux
build produced zero artifacts and still exited zero. That is fixed in the
config change; this wires the pipeline to it.
Four legs now: macOS on both architectures, Windows NSIS, Linux .deb. NSIS
rather than MSI because MSI can only be built on Windows, needs an optional
Windows feature, and installs to Program Files behind a UAC prompt; NSIS
installs per-user with no administrator password. .deb only -- no RPM, no
AppImage. AppImage needs libfuse2, which Ubuntu 24.04 removed, and renders a
blank window on some Wayland and Mesa combinations; a .deb that works beats an
AppImage that half does.
Two decisions that look arbitrary and are not:
* The Linux leg pins ubuntu-22.04. glibc is forward-compatible only, so the
build host sets the *oldest* distribution the package can start on. Building
on 24.04 would emit a .deb that refuses to launch on 22.04 or Debian 12 with
a GLIBC_2.38 error no runtime flag can fix. That image begins deprecation on
2026-09-17 and retires 2027-04-17; the comment says to move to a debian:12
container rather than bump the runner, so the floor stays our decision.
* libappindicator3-dev, not the ayatana package the Tauri docs suggest. The
.deb's generated Depends: follows whichever the binary linked against, so
mixing them between builds produces packages that disagree about their own
dependencies.
A `verify` job now fails the release if any platform is missing an asset. A
release that quietly ships three platforms instead of four is worse than one
that fails, because nobody notices -- which is how v4.7.1 went out with zero
assets in the first place.
The install doc covers all four downloads, and says plainly what each platform
does about an unsigned build: Gatekeeper's misleading "damaged" message and the
right-click-Open, SmartScreen's hidden Run anyway, and `apt install ./file.deb`
rather than dpkg so dependencies resolve.
Guarded by tests, because these three files must agree and none of them fails
loudly when they do not: the workflow matrix, the config's bundle targets, and
the filenames the install doc tells people to download. 9 tests, 11/11
mutations caught -- including dropping a platform, swapping the appindicator
package, bumping the Linux runner, and removing the Windows icon.
One of those tests was weak on the first attempt: it grepped the whole file for
`exit 1`, which appears in the verify job too, so it passed with the
artifact step's guard removed. It is now scoped to that step.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The bundle targets were `["app", "dmg"]`. Both are macOS-only bundle types, so
a Windows or Linux build produced *zero artifacts* and still exited zero --
the failure mode is a green build with nothing in it. Added nsis and deb.
`titleBarStyle: "Overlay"` and `hiddenTitle` are macOS-only keys, silently
ignored elsewhere, which left a native title bar sitting on top of the CSS one.
They now live in tauri.macos.conf.json, which Tauri merges per platform.
`csp: null` disabled the content security policy entirely. It is now explicit,
and allows exactly what this app needs: loopback to the engine, which is how
the webview talks to Python at all.
On the UI side the page now reads which platform it is on and adapts three
things that were macOS facts written as layout constants:
* 5.2rem of left padding for the traffic lights. Windows and Linux put their
window controls top-*right*, so this was dead space on the left and an
overlap with the toolbar on the right.
* `⌘N`, `⌘K`, `⌘,` labels. The keydown handler already accepted Ctrl, so the
shortcuts worked -- the hints just named a key those keyboards do not have.
They now come from one place, so a new shortcut cannot be added with a
hardcoded modifier that is wrong on two platforms out of three.
* "into your Library folder", and a Copy button that copied
`~/Library/Application Support/PraisonAI` on machines where no such path
exists. Windows gets %APPDATA%, which pastes straight into Explorer.
Detection is layered -- userAgentData, then navigator.platform, then the user
agent string -- because the last is the easiest to be wrong about: a webview
reporting only "(darwin)" matched none of the names and fell through to Linux
on a Mac. That was caught by a test, not by reasoning.
Two test defects fixed along the way, both of which had been passing while
proving nothing:
* `first-run.test.mjs` asserted the lede contains "Library". That passed only
because this suite has only ever run on macOS; on the Linux runner this
release needs, it would fail on correct behaviour -- and anyone fixing the
copy would have "broken CI" for doing the right thing. It now asserts the
copy names *a* place, whichever platform is reading it.
* The new platform tests originally passed jsdom's `userAgent` option, which
is silently ignored, so all three cases ran against jsdom's own agent and
were really testing one platform three times. The navigator properties are
now defined directly. A second version read `body.textContent` -- which
includes the inline script's source -- and "found" macOS copy on Windows
inside the source of the very branch that avoids it.
Also generated icons/icon.ico: without it tauri-build does not warn, it
refuses to compile for Windows at all.
Tests: 6 new platform tests, 7/7 mutations caught. Full suite 124 passing, and
verified still passing with the page forced to report Linux.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The crate always compiled for all three platforms -- there is exactly one
`#[cfg]` in it -- so nothing ever failed loudly. Every defect here is a runtime
one: the app starts and then cannot find its own engine.
Platform is now a value rather than a `cfg`. Every path and process decision
that differs goes through a function taking one, because `#[cfg(windows)]`
makes the Windows branches invisible to a suite that only ever runs on macOS,
and invisible branches are how the code nobody runs comes to be wrong.
What was broken:
* `HOME` was the only home lookup. It is normally unset on Windows -- that is
`USERPROFILE` -- so provisioning returned "no home directory" and the
orphan-reclaim path never ran at all.
* Every interpreter candidate ended in `bin/python3`. A Windows venv keeps its
interpreter in `Scripts\python.exe`, so all of them failed `is_file()` and
the app reported "no usable Python found" immediately after successfully
provisioning one.
* The uv bootstrap ran `/usr/bin/curl` piped into `/bin/sh`. Neither exists on
Windows, so first-run provisioning was simply dead; and pinning curl's
absolute path breaks Linux images where it lives elsewhere. There is now a
PowerShell installer for Windows, and curl is found by name.
* `/bin/kill` was spawned unconditionally to reap an orphan, and the call site
discards the result -- so on Windows, deciding to kill an orphan killed
nothing, which is the exact bug reclaim.rs exists to prevent.
* `/bin/ps -o lstart=` was the only start-time source, so every pid on Windows
read as "no such process", every live engine looked dead, and a second one
started beside it on every launch. Windows now reads the creation stamp from
kernel32.
* site-packages was derived as `lib/python3.X/site-packages`. Windows is
`Lib\site-packages` with no version segment -- and the caller's only check is
that the path sits under the venv root, so the wrong path passed the guard
while pointing at nothing, which is worse than failing.
* `spawn_env` joined PATH with `:` and used `bin`. Windows is `;` and
`Scripts`; one wrong separator makes the entire PATH a single unusable entry.
The shell and the engine must also agree on two things or they fail silently
and permanently: the data directory, and the hash that identifies a running
process. Both are now asserted from both sides -- reclaim.rs and
test_portability.py carry the identical fixture table, so if either hashing
implementation drifts, both suites fail rather than the two quietly disagreeing
and starting an engine per launch forever.
The PowerShell installer deliberately avoids `-WindowStyle Hidden` with
`-ExecutionPolicy Bypass`: that pair is a known malware detection signature and
gets installers flagged. The console is suppressed with a creation flag, which
is not a behavioural signal. `UV_NO_MODIFY_PATH` is set on every platform,
because an app should not rewrite the user's shell profiles to install its own
dependency.
Also generated the icon set. `icons/icon.ico` was missing, and tauri-build does
not warn about that -- it refuses to compile for Windows at all.
Verified rather than assumed: `cargo check --target x86_64-pc-windows-msvc
--all-targets` passes, so the FFI and the CommandExt code that macOS never
compiles is at least type-checked. Linux cannot be checked from here because
libdbus-sys needs system headers that only exist on Linux; CI covers it. 116
tests pass on the host.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Six independent macOS assumptions. Two of them kill the engine outright on
Windows; one makes settings unsavable on Windows *and* Linux.
The signal registration was the worst, because of where it failed rather than
how. `for sig in (SIGTERM, SIGINT, SIGHUP)` builds the tuple before the loop
body, so SIGHUP is dereferenced outside the try that was meant to tolerate a
missing signal -- and Windows has no SIGHUP. It raised AttributeError, which
that except clause would not have caught anyway. It happened *after* the port
was announced and the lockfile written, so the shell adopted an engine that
was already dead and every request looked like a network fault. Signals are
now looked up one name at a time.
Secrets went to the macOS keychain via the `security` binary, which does not
exist elsewhere: the write reported failure, POST /settings raised with no
handler, and load_settings reported the key as unset forever. The app was
permanently unauthenticated on both other platforms with no visible cause.
There is now a store per platform -- keychain, DPAPI, libsecret -- each backed
by a 0600 file, because a machine with no keyring daemon (headless, container,
locked login keyring) must still be able to save a key. A secret is never
written into settings.json, which is the file people paste into issues.
The data directory was `~/Library/Application Support` everywhere. On Windows
that is not merely unconventional: it is excluded from roaming profiles and
from most backup tooling, so a user's entire history silently fails to follow
them to a new machine. Now %APPDATA% and $XDG_DATA_HOME respectively.
Three quieter ones. The lockfile was written in the locale encoding while the
Rust side reads it as strict UTF-8 and maps invalid bytes to "absent" -- and
absent means spawn, so a non-ASCII home directory would have produced a second
engine beside the live one on every launch. `read_file` decoded user files in
the locale encoding with errors replaced, handing the model plausible mojibake
it would reason over confidently. And SO_REUSEADDR, correct on POSIX, means
"another process may bind this address while I am listening" on Windows --
on unauthenticated loopback carrying API keys.
Also: the recycled-pid fingerprint shelled out to `ps` and returned 0 on
Windows, and 0 is the "could not tell" value, so every recycled pid compared
equal and an unrelated process would be adopted as the engine.
GetProcessTimes gives a real creation stamp.
One incident worth recording. Verifying this live wrote a test key into my own
macOS keychain, because PRAISONAI_DESKTOP_HOME isolates the data directory but
not the system keyring, which is shared per user. KEYCHAIN_SERVICE is now
overridable so a test cannot reach the real one. Asking for isolation should
isolate everything.
Tests: 27, all mutation-checked -- 13/13 mutations of these guarantees are
caught. Four of those tests were rewritten after the first versions survived
their mutations: the XDG test compared a path to itself (its "custom" value
was the default), the two encoding tests could not fail on a UTF-8 Mac and now
run under LC_ALL=C in a child process, and the permission test asserted the
final mode, which passes even when the file is created 0644 and chmodded
afterwards -- leaving a window where any local process can read the key.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The desktop app could talk to a model but not make one, while praisonai-train
sat behind it doing exactly that. This wires the two together.
A training run is a subsystem rather than a route. The engine is a
ThreadingHTTPServer where every request is a turn that starts, streams and
ends -- right for chat, wrong for something that runs for hours, must survive
the window closing, and has to resume after a reconnect.
Three decisions, each taken because the alternative was worse:
* One run at a time. Two fine-tunes on one GPU do not co-exist; they OOM.
Starting a second returns 409 naming the run that is already going, rather
than queueing, which would imply a scheduler nobody asked for.
* Progress is a replayable ring buffer, not a stream. Reconnecting reads from
a cursor, so a closed lid loses nothing; if history was evicted the client
is told to resync rather than handed a log with a hole in it.
* The subprocess owns the truth. Status comes from the process and its log,
never from what the client last heard, so a client that missed the ending
still learns how it ended.
Three defects the tests found while being written, all of which would have
shipped:
* The event cursor was derived from len(events). Once eviction begins the
length stops growing, every later event is handed the same cursor, and
since() returns nothing forever -- a live run whose UI silently stops
updating. It is now a standalone counter.
* run_id came from the client and became a directory name. The engine listens
on loopback with no auth by design, so any local process could have posted
`../../../Library/LaunchAgents/x`. Now allowlisted.
* Stopping a run signalled the process group without checking whose group it
was. The mutation that removes start_new_session proves the hazard is real:
it makes the test runner kill itself. The engine now refuses to signal its
own group.
Portable from the start, since Windows and Linux builds are next: the pipe is
decoded as UTF-8 rather than the locale encoding (cp1252 turns unsloth's
banner into mojibake, and five of its bytes raise inside the reader loop,
freezing the log while the run continues); PRAISONAI_TRAIN_CMD is split with
Windows rules on Windows, where POSIX splitting eats every backslash in an
interpreter path; the child gets CREATE_NEW_PROCESS_GROUP there, because
CPython accepts start_new_session on Windows and then ignores it; and run ids
reject reserved device names, which an alphanumeric allowlist cannot see.
Tests: 42 engine unit tests, 10 end-to-end tests against a real spawned
engine, 16 UI tests driving the real page in a DOM. All 9 mutations of the
engine's guarantees are caught, and all 7 of the UI's. The existing desktop
suite stays green at 134.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Releases carried no assets at all. v4.7.1 shipped zero, so a Mac user had
nothing to download and no path to the app that exists in this repo.
On a published release (or by hand against an existing tag) this builds a .dmg
on macos-14 and macos-13 and uploads both:
PraisonAI-<version>-macos-apple-silicon.dmg
PraisonAI-<version>-macos-intel.dmg
Two per release rather than one universal binary, because Tauri does not
produce a universal build and a wrong-arch download fails at LAUNCH rather than
at install -- which is a worse place to discover it.
The build fails loudly when no .dmg is produced instead of uploading nothing.
An empty asset list is exactly the state this workflow exists to fix, and a
silent no-op would recreate it while looking green.
Unsigned, deliberately: signing needs a Developer ID in repository secrets, and
shipping an unsigned build people can right-click-open beats shipping none.
INSTALL-macOS.md says so plainly rather than letting Gatekeeper say it -- its
message for an unsigned app is "PraisonAI is damaged and can't be opened",
which is both alarming and untrue. The guide covers which architecture to pick,
the right-click-Open path, the xattr fallback, what the first-run provisioning
does and where it puts things, and how to remove it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Rebuilt on current main after #4367, #4368 and #4370 merged.
An adversarial audit ran 16 mutations against this suite. EIGHT survived all
408 tests, and every survivor was covered only by
src = inspect.getsource(some_function)
assert "the_thing_i_want" in src
which passes whether or not the code does anything: the string is still in the
file when the branch around it is disabled.
The worst case was a test holding a COPY of the logic it tested.
test_response_masking.py defined _auto_use_mask() mirroring train_model's
`auto` expression, so reverting the real line to the pre-fix `supports_mask`
alone left the whole suite green -- the single defect that PR existed to fix
was undetectable by its own test file. The expression is now
resolve_mask_setting() and the test calls it; that mutation now fails.
The guard caps source-text assertions, bans reimplementing tested logic, and
asserts the extracted helpers stay importable -- each of resolve_mask_setting,
decide_masking, resolve_response_markers, is_out_of_memory, rewards.require and
rewards.resolve_all exists because a source-text test failed to catch a bug,
and inlining one silently returns its test to proving nothing.
The cap is a ratchet, not a ban: ordering constraints are genuinely structural.
It tripped twice on rebases while this PR waited, which is the point. Both
times the new assertion turned out to be convertible and was converted rather
than waved through: _launch_script RETURNS its command, and the PEFT forwarding
list is data, now the module constant PEFT_PASSTHROUGH.
Two things the guard got wrong first, both found by running the regression
rather than reading it: it matched the COMMENT explaining the removed copy, so
it now tokenises and drops comments and strings; and joining tokens with spaces
turned `inspect.getsource` into `inspect . getsource`, so the count missed
every occurrence.
483 tests passing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Each of these is a keyword unsloth already accepts and this package never
passed. Nothing failed when they were missing -- the run just did something
narrower than the user asked for.
QUANTS: 12 of unsloth's 24 were refused by this validator before unsloth was
ever asked -- q3_k_l, q4_k_s, q5_k_s, f32 and the aliases are all legal
upstream. The IQ family (11 imatrix quants) was unreachable entirely, which is
how a 30B model fits on a laptop. Now 35 accepted, read out of unsloth's own
save.py rather than hand-picked, with a test that fails when the two drift.
MODEL ACCESS: hf_token, trust_remote_code and revision were unreachable, so a
gated repo (Llama, Gemma) worked only through an environment variable, a
custom-code model not at all, and a multi-day run could not pin a revision --
the tag moves and the run is no longer reproducible.
PEFT SELECTORS: the target-module list was hardcoded to seven names.
finetune_last_n_layers (the cheap-run recipe), target_parameters (required to
LoRA MoE experts), layers_to_transform, layers_pattern and init_lora_weights
are all forwarded now.
OFFLINE MERGE: save_pretrained_merged was never called, so the only way to get
merged weights was to PUSH them -- needing a Hub account, a token and a
network. `merged_save_dir` writes them to a directory.
vLLM: fast_inference was never passed, so GRPO always ran the slow HF-generate
rollout path.
28 tests. Five mutations, all killed.
One of them could not be expressed at first, because the test helper
REIMPLEMENTED the assembly it was checking -- in the very change that bans
reimplementing tested logic. It is now model_access_kwargs() and the test calls
it; deleting the token forwarding fails.
480 tests passing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A `git stash --include-untracked` during merge-conflict repair swallowed the
whole untracked praisonai-desktop tree and it was never restored. It surfaced
only because a UI audit described settings as inert that had been fixed days
earlier -- true of the tree on disk, not of the work. Recovered from
stash@{0}^3.
What it contains, all previously reported and none of it on main:
- The eleven dead settings wired to real behaviour, each proven by a test swept
across at least two values.
- Text size scales the whole interface rather than only the chat, with every
fixed-pixel dimension converted to rem and a test that fails on any
dimensional px over 3px.
- The transcript and the composer share one column. They were ~240px apart
because an auto margin on a flex item's cross axis overrides `stretch`.
- In-app confirmation dialogs. window.confirm returns false immediately in a
WKWebView without showing anything, so every guard written as
`if (!confirm(...)) return;` cancelled -- which had silently broken deleting
a conversation outright.
- data-tauri-drag-region plus core:window:allow-start-dragging, without which
the titlebar could not move the window: -webkit-app-region is a Chromium
feature WKWebView does not implement.
- Accessibility: landmarks, a live region for streamed answers, accessible
names on icon-only controls, a focus-trapping dialog, aria-current on rows.
- Real app and menubar icons from the PraisonAI mark. A macOS template image is
drawn from its alpha channel alone, so the previous one rendered as a solid
block.
- First-run provisioning with staged progress, message versions with a
`< 1/2 >` picker, and orphan reclamation through the lockfile the Rust side
had always specified and nothing wrote.
118 JS tests across 11 suites, 90 Rust tests, all passing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Three agents audited the eleven PRs that just merged. Every defect below passed
the suite that shipped it.
FIVE IN THE CODE
1. --dry-run showed one config and a different one trained. Forwarding
--dataset/--model -- which I added as "belt and braces" -- puts the legacy
dispatcher on its regeneration branch (praison_ai.py:676), where
generate_config() runs with every tuning parameter None and rewrites
config.yaml from defaults: lora_r 64 -> 16, epochs 5 -> 1, method dpo -> sft,
and huggingface_save false -> TRUE with hf_model_name defaulted to a third
party's repository.
2. cpt silently lost embedding_learning_rate, the one value it exists to set.
__dataclass_fields__ is inherited, so on a plain __init__ subclass it resolves
to the parent's fields and the drop-unknown filter deleted the value 45 lines
after setting it -- along with max_seq_length and packing.
3. remote start could never train: it passed config.yaml as the dataset
positional. Two positionals meant exit 2 before the GPU was touched.
4. Remote command injection and path traversal. --run-id 'x;touch /tmp/pwned'
executed, because the far half of an scp target is expanded by a shell there.
fetch ../../../../etc/passwd passed the existence gate and copied the file
back. Both quoted and validated now.
5. hf_private failed open: 'private', 'y', 'enabled', 5 all published the
repository.
FIVE AT THE BOUNDARY
6. llm overwrote ./config.yaml with no backup, and had already destroyed a
hand-written one in this repo's working tree.
7. praisonai train exposed 6 of 15 commands and no remote group, because the
integrated CLI imports cli/commands/train.py while the siblings register via
cli/app.py.
8. tests/unit/data/ -- 96 tests -- was collected by no workflow at all.
9. The suite needed 3.11 while pyproject declares >=3.10.
10. pip install "praisonai-train[llm]" produced an llm command that died at
argument parsing; nothing declared the praisonai wrapper it imports.
44 tests. Ten mutations, all killed -- four only after fixing the test first: a
fake class with **kwargs returned early, two called a validator the code path
no longer had to use, and one matched the comment explaining the fix rather
than the configuration. That last shape has now appeared three times.
452 tests passing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Rebuilt again on current main, which moved when four sibling PRs merged.
The last of unsloth's training methods. It rewrites every trl.trainer.*_trainer
module generically (rl.py:4235-4248), so all three work through it. GRPO was
the blocker for the rest, because it needs reward functions and a YAML config
can only carry a string.
DECISION, stated because it is the one thing I could not derive: reward
functions are named by dotted import path, `module:function`.
method: grpo
reward_funcs:
- myproject.rewards:length_penalty
num_generations: 8
A registry populated by decorator would mean the config can only name functions
from a module something else already imported. Inline Python in YAML is a
code-execution surface in a file people paste from the internet. An import path
is the only one of the three resolvable before anything runs -- which is why
rewards resolve before the model loads, so a typo costs five seconds rather
than a multi-gigabyte load. If you would rather have a registry, the change is
contained to rewards.py.
A missing module and a missing attribute are told apart, since one is a
PYTHONPATH problem and the other a typo; the attribute error lists the
callables that are there. Every broken path is reported at once. GRPO takes
prompts only, since it generates its own completions.
394 tests passing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Rebuilt again on current main, which moved when #4361/#4362/#4364/#4365 merged.
praisonai-train checkpoints
praisonai-train infer -d lora_model "Summarise this release."
checkpoints: after an interrupted run there was no way to find what was saved
without `ls`. Matches checkpoint-<n> exactly, so a directory called
checkpoint-final is not reported as step 0, and sorts numerically.
infer: TrainModel.inference() existed with no caller anywhere in the package.
serve --benchmark measures tokens per second without showing a token.
Streamed, with usable defaults rather than the dead inference()'s
temperature=1.5 / max_new_tokens=64.
Named infer, not generate: generate is already dataset generation, and a second
command of that name silently replaces one with the other.
Also one line: dataset_num_proc defaults to None rather than 1, so unsloth's
memory-aware sizing is not overridden into a single process.
386 tests passing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Rebuilt on current main; the previous branch carried eight unrelated commits.
The `models` command now lives in its own module rather than alongside the
post-training commands, so this change does not depend on them.
model_name was free text with no validation anywhere. A typo was caught by
Hugging Face -- after the CLI accepted it, after the environment was built,
sometimes after a partial download. And nothing told a new user which models
work: config.yaml offers exactly one example, from the Llama-3.1 era.
praisonai-train models
praisonai-train models gemma
praisonai-train models --json
The catalog is read from unsloth's mapper at runtime, not vendored: it carries
246 distinct 4-bit repos and is updated every release, so a copy here would be
stale the day it shipped. CURATED is the fallback for when unsloth is absent --
the CLI is importable without the training extra -- and the starting point for
someone who has not chosen yet.
An unknown model warns rather than refusing, because unsloth loads plenty it
does not map. Suggestions match the repo name, not the full id, so an upstream
id surfaces the unsloth mirror. The catalog includes mapper values as well as
keys, since a 16-bit mirror appears only as a value and naming one would
otherwise be reported as unknown.
335 tests passing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Rebuilt on current main; the previous branch carried nine unrelated commits.
Two ways to end up with an environment that looks fine and cannot train.
Version floors were pip metadata only. pyproject declares torch>=2.6.0 and the
rest, but nothing checked at runtime -- and pip is routinely bypassed:
--no-deps, a hand-built conda env, setup_conda_env.sh pinning its own torch.
The failure then arrived as whatever traceback the mismatched package happened
to raise, several imports deep. A test asserts the floors match pyproject
exactly: lower lets a broken environment through, higher refuses one pip would
have built.
The CUDA pin ignored the hardware. Every Linux host got pytorch-cuda=12.4
regardless. On AMD or Blackwell that builds a working-LOOKING environment that
fails at the first kernel; on a CPU host it installs a CUDA build that can
never run. There is a probe now -- nvidia-smi, then /proc/driver/nvidia/gpus
for a host with the driver but not the tool, then rocminfo -- and the pin lives
in one place instead of four copies across two branches.
A CPU-only Linux host is told plainly that training will refuse, since
trainer.py raises on a missing GPU. A prerelease of the required version counts
as satisfying the floor, because refusing 2.6.0rc1 blocks people testing
upstream and unsloth's own checks are release-level.
338 tests passing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Rebuilt on current main; the previous branch carried four unrelated commits.
A raw-text corpus could not load. A dataset with only a `text` column fell
through to the Alpaca branch, formatted every row to "", and died with "All
examples formatted to empty text" — an error blaming the dataset for a shape
the formatter did not handle. Domain and language adaptation could not run.
And the embeddings got the wrong learning rate. modules_to_save was exposed, so
a user could make embed_tokens/lm_head trainable, but the trainer was plain
SFTTrainer, which has no embedding_learning_rate. They trained at the adapters'
rate — the recipe unsloth ships UnslothTrainer to avoid.
method: cpt
modules_to_save: [embed_tokens, lm_head]
embedding_learning_rate: 2e-5 # defaults to learning_rate / 10
Raw text passes through with no tokenizer call; ShareGPT and Alpaca still win
over a stray `text` column; cpt is formatted like SFT rather than taking the
preference passthrough. The empty-dataset error now names the three shapes and
prints the columns the dataset actually had.
326 tests passing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Rebuilt on current main; the previous branch was cut from its predecessor and
carried five unrelated commits.
`praisonai-train llm` took three flags to `unsloth train`'s thirty-eight, and
was the one command that would not accept --config though export and serve
both do. Eleven flags now, plus --config and --dry-run.
Two fixes on top of the original branch, both from problems main surfaced:
train_llm is called directly as a plain function, not only through Click --
main's own tests do it. Unfilled Typer parameters then arrive as OptionInfo
objects and Path(config) raises TypeError. They are unwrapped to their declared
defaults first.
The dataset and model reach argv as well as the resolved config.yaml. The
config path alone is the better mechanism, since the legacy dispatcher drops
every flag it does not declare -- but an earlier incident had the dataset
dropped entirely and the run trained on a public Alpaca mirror, discovered
after the GPU time. Passing both means neither path can lose it, and main's
regression test for that incident keeps passing.
330 tests passing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Rebuilt on current main. The previous branch was cut from its predecessor
rather than from main, so it carried three unrelated commits and conflicted
once those merged. This is the change on its own.
praisonai_train/remote/runner.py was never in the repo. It was untracked —
present in a working tree, absent from git — so the CLI wired to a module that
did not exist and every command raised ModuleNotFoundError. That is what the 7
CI failures were, and the runner here is the one the triage agent wrote to fix
it: stdlib only, no new dependency for a feature most users never touch.
The tests did not catch it because every one of them replaces `_runner` with a
fake. A fake is the right tool for the CLI's behaviour and the wrong tool for
"does the thing I am faking exist" — so there are now three tests that import
the real module, check it has the six methods the CLI calls, and assert it
pulls in nothing outside the standard library. Removing the module fails ten
tests where it used to pass fourteen.
praisonai-train remote preflight gpubox
praisonai-train remote start gpubox -c config.yaml -d data/train.jsonl
praisonai-train remote tail gpubox run-1787...
praisonai-train remote fetch gpubox run-1787... lora_model -o ./out
praisonai-train remote stop gpubox run-1787...
start prints the run id and the stop command before it streams, so a Ctrl-C
during --follow does not leave a job running with no handle. A preflight
failure and a refused start both exit non-zero. stop reports when nothing was
running rather than claiming success.
319 tests passing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two runtime bugs flagged in review:
- load_datasets() called logger.warning but the module never defines a
logger, so any dataset with processing_func set raised NameError and
aborted the run. Switched to print(), matching every other warning in
this module.
- Response-only masking (train_on_responses_only) rewrites SFT label
tensors; it is meaningless for DPO/ORPO/KTO, whose trainers build labels
from chosen/rejected pairs. The decision ran before method was read and
applied unconditionally, so an enabled masking setting could corrupt a
preference run. Gated the whole masking block on method == "sft".
Co-authored-by: Mervin Praison <[email protected]>
Two things the tool told the user that were not true.
OOM. torch.cuda.OutOfMemoryError subclasses RuntimeError, so the CLI's
catch-all flattened it into one ERROR line carrying torch's raw allocator dump.
It is the most common fine-tuning failure and the one where a first-time user
has no idea which number to change. The handler now classifies it and prints an
ordered remedy — sequence length first, smaller model last — matching the
ordering unsloth validated in its own backend
(studio/backend/core/training/worker.py:4834-4845). A test asserts every knob
the remedy names is actually accepted by the config, so the advice cannot drift
from the schema.
README. Two quickstart commands did not exist as written:
praisonai-train data from-trials ... # there is no `data` sub-app
praisonai-train llm --dataset ... # dataset is positional
The second is the exact shape of an incident this code already carries a
comment about (cli/commands/train.py:65-68): a dataset passed the wrong way was
silently dropped and the run trained on the default corpus, discovered only
after the GPU time was spent. A reader who "fixes" --dataset by dropping the
flag walks straight into it.
A test now parses every `praisonai-train <sub>` in the README and asserts it is
a registered command, so the docs cannot drift from the CLI again.
While writing the tests I deleted a branch I had just added: a separate
type(exc).__name__ == "OutOfMemoryError" check was redundant with the
"outofmemoryerror" text marker, since the class name is folded into the
searched text. No mutation could distinguish it from its absence, which is the
definition of code a test cannot cover.
12 tests. Mutations killed: the remedy never printed, the marker dropped, and
each README command reverted. Full suite 265 passing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
In `assistant_only_loss: auto` (the documented default), use_mask was keyed
off TRL's `_supports_assistant_mask()` alone. Every unsloth template lacks
`{% generation %}`, so that returned False, use_mask became False, and
decide_masking short-circuited to full-sequence loss before the turn-marker
route was ever considered -- the exact defect this PR set out to fix. Auto now
enables masking when EITHER route is usable and lets decide_masking pick.
Adds regression tests covering the auto-normalization coupled with route
selection, the gap that let the bug survive.
Co-authored-by: Mervin Praison <[email protected]>
Resolves three P1 issues from review on PR #4357:
- Vision pushes (train_vision.py, upload_vision.py) now wrap push_to_hub
in the shared hf_push_errors context manager, so a 401/403 becomes the
same actionable advice the LLM path gives instead of a raw traceback.
- hub_push_kwargs default coercion is now string-aware: vision callers
that omit `flag` read hf_private: "false"/"0"/"no" as public instead of
bool("false") == True keeping the repo private against the user's wish.
- Legacy single-element quantization_method lists are normalized back into
config in both validate_config and for_export, so the GGUF exporter
(which re-reads self.config) receives the method string, not a list.
- train_vision.save_model_merged now uses the guarded clean_local_repo_dir
instead of an unguarded rmtree that could delete a namespaced repo path.
Co-authored-by: Mervin Praison <[email protected]>
Every instruction-tuning run was training on the prompt as well as the answer.
`assistant_only_loss` is TRL's mask and it needs `{% generation %}` in the chat
template. None of unsloth's 43 templates contain it — grep -c "generation %}"
unsloth/chat_templates.py returns 0. So `assistant_only_loss: auto`, the
documented default, resolved to False for every template a user can actually
select. There was no error; the run summary printed "Loss mask: full sequence"
as though that were the intended outcome.
Unsloth's own answer is train_on_responses_only(trainer, instruction_part,
response_part) (chat_templates.py:58-87), which masks by locating literal turn
markers and works on any template. It is applied to the built trainer rather
than through the config, because it rewrites label tensors on the tokenised
dataset.
That needs the two markers, which vary by family, so this adds a table for
llama-3, chatml, gemma, mistral, phi and zephyr, and a resolver that falls back
to the model name when no chat_template is set. Unknown models resolve to None
rather than a guess: masking on the wrong markers finds no boundary and trains
on nothing, which is worse than not masking.
Two things worth review:
- Writing the resolver's tests found a real bug in it. Every unsloth model is
"unsloth/<name>", and "unsloth" is itself a template key, so scanning the
full repo id sent every one of them to the chatml markers — including Gemma
and Llama models whose markers are different. It now matches the repo name,
not the org.
- The summary line now names which route masked. "assistant replies only" hid
the distinction between two mechanisms with very different coverage, and the
unmasked case now says what it costs: "full sequence (training on prompts
too)".
decide_masking() is separate from train_model so the routing can be tested
directly. The first version of that test read train_model's source for the
string "train_on_responses_only" and passed with the calling branch disabled —
the same shape of weak test that let the original defect survive.
18 tests. Five mutations, four killed: removing the fallback route, matching
the org prefix again, making two markers identical, and forcing masking on.
The survivor is collapsing the two routes into one truthiness check, which is
covered by the label test rather than the router. Full suite 253 passing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
process_dataset applied the SFT formatter unconditionally, collapsing every row
to a single "text" column before the preference column check ran -- so a
correctly shaped DPO/ORPO/KTO dataset lost the very columns its trainer needs and
was rejected before trainer construction, making all three new methods
unreachable. Skip SFT text-formatting for preference methods so prompt/chosen/
rejected (and prompt/completion/label) survive intact.
Also fixes an undefined `logger` in load_datasets that raised NameError whenever
dataset.processing_func was set; switched to print to match the module.
Adds regression tests covering both the preference bypass and the unchanged SFT
path.
Co-authored-by: Mervin Praison <[email protected]>
Every model this tool uploaded became a PUBLIC repository. `private` was never
passed to push_to_hub_merged or push_to_hub_gguf in any of the three places
that push — train/llm/trainer.py, train_vision.py and upload_vision.py. Unsloth
accepts the flag (save.py:2654, create_huggingface_repo(private=...)); nothing
here forwarded it. A model fine-tuned on private data was published the moment
training finished, silently.
The default is now private. Publishing is one click in the Hub UI; unpublishing
something that has already been crawled is not. Set hf_private: false to
publish.
The same duplication hid two more defects, both in upload_vision.py, both
already solved in its LLM twin:
- No error handling at all, so a rejected token surfaced as a raw
HfHubHTTPError traceback. The LLM copy has translated 401 and 403 into
actionable messages for a while.
- An unguarded rmtree: it deleted config["hf_model_name"] whenever a directory
of that name existed, with no check that the value was a local path rather
than a Hub repo id. A bare name like "mymodel" deleted ./mymodel.
All three now share praisonai_train/_hub.py, so the next fix lands in one place
instead of one of three. save_method, commit_message and tags are forwarded
too — previously save_method was hardcoded to merged_16bit at all three sites.
Found by an agent diffing unsloth's save.py against these files.
14 tests. The privacy default is asserted directly, the YAML "false" string is
covered (without the trainer's coercion it is truthy and would keep the repo
private against the user's wish), and a test asserts no push site still uses a
bare token as its only kwarg — the signature of the original defect.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The DPO/ORPO/KTO support in the previous commit could never have worked on a
real corpus, and the tests did not catch it because they exercised the column
check in isolation rather than through process_dataset.
process_dataset ends with
dataset.map(format_func, remove_columns=dataset.column_names)
which collapses every row to a single `text` column for SFT. Applied to a
preference dataset that destroys prompt/chosen/rejected before the trainer ever
sees them, so every DPO run would have died with
method 'dpo' needs the column(s) ['prompt', 'chosen', 'rejected']
and the dataset has ['text']
— an error message that blames the user's file for something this code did to
it moments earlier.
Non-SFT methods now return the dataset with its columns intact, after the
shuffle that the SFT path would also have applied. Found by an agent reading
unsloth's rl_replacements.py against this file; the preference trainers there
read those columns by name (rl_replacements.py:163-201, :850-931).
Two tests, both asserting order rather than outcome: the method guard must
appear before the flattening call and must return rather than warn, and the SFT
path must still flatten. Removing the guard fails the first.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Unsloth patches TRL's DPO, ORPO and KTO trainers alongside SFT — they are
named in the list of trainers it rewrites at unsloth/__init__.py:1438-1443.
praisonai-train could only ever construct an SFTTrainer, so a dataset of
chosen/rejected pairs had nowhere to go and the whole preference-tuning half
of the backing library was unreachable.
method: dpo # or orpo, kto; sft remains the default
beta: 0.1
max_prompt_length: 1024
TRAINING_METHODS is a registry: each entry names the TRL trainer and config
class, the columns the dataset must provide, and whether the method needs a
frozen reference model. Adding a method is one entry.
Three things worth calling out:
- ORPO declares needs_ref_model: False. It folds the reference into its loss,
so holding a second frozen model would double VRAM for nothing.
- DPO and KTO pass ref_model=None, which means "use the frozen base weights".
With a PEFT adapter that is exactly right and avoids a second full model.
- The dataset's columns are checked before the run starts. TRL's own failure
for a wrongly-shaped preference dataset surfaces inside the collator —
minutes in, after the model is loaded and quantised, and phrased in terms
of tensors rather than the file the user pointed at.
The TRL imports are individual and lazy, so a TRL too old to provide one
trainer can still run the others rather than failing at import; a method whose
classes are absent raises a message naming what to upgrade.
resolve_method() is separate from validate_config() so the method surface can
be tested without a CUDA import.
19 new tests. Five mutations, all killed: accepting an unknown method, dropping
case normalisation, disabling the column check, giving ORPO a reference model,
and having validate_config skip the check. Full suite 233 passing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Extract byte-identical prep and finalize logic from the sync
_execute_single_step and async _aexecute_single_step into three private
helpers (_prepare_step_context, _build_step_action, _finalize_step_result),
so both variants share a single source of truth. Genuine async seams
(handler-coroutine await, achat executor branch, retry-loop await) remain
per-variant. Pure behaviour-preserving extraction; no public API change.
Co-authored-by: MervinPraison <[email protected]>
Delete unreferenced 680-line EnhancedTemplateGenerator module that is
never imported, wired into the CLI, or exported. The canonical, wired
generator remains standardise/templates.py::TemplateGenerator.
Co-authored-by: MervinPraison <[email protected]>
The regression test for #4229 eagerly constructed Agent(memory=preset) for
every advertised preset, which initialised live memory adapters (Mem0/MongoDB)
and failed CI without API keys or running services. Assert resolver correctness
instead, matching resolver-only test patterns elsewhere.
Co-authored-by: MervinPraison <[email protected]>
- ui/index.html: handle approval_request SSE events with an inline
Allow/Always/Deny card that POSTs /approve/{id}. Without this, the default
approval_mode:ask stalled every gated file/URL tool for the full 300s
timeout because the shipped UI never resolved the decision.
- engine/server.py: _apply_env now removes OPENAI_API_KEY / OPENAI_API_BASE
when a value is cleared, instead of skipping it — clearing a key or base_url
no longer leaves the deleted credential/endpoint active for later turns.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
A Tauri shell supervising a Python engine on loopback. The streaming path is
kept off the IPC bridge: the webview reads SSE from 127.0.0.1 directly, which
is what jan, unsloth studio, ollama and hermes all independently converged on.
src-tauri/ Rust core, pure and I/O-injected so every decision is testable
without a Mac, a certificate, a network or an installed Python:
venv_resolve one function owns both interpreter and
site-packages, catching the silent case where
3.13.2 and 3.13.7 share a path and nothing raises
verify half-installed is Stale, not Ready
health a server answering failures with HTTP 200 cannot
be trusted by status code alone
readiness log phrasing is upstream and versioned, so it is
a tested pure function
adopt five gates before reusing an orphan; a recycled
PID is never killed
lockfile absent and corrupt get different answers
port_announce an announced port is a claim until probed
engine/ stdlib-only HTTP + SSE: streaming, cancellation, sessions,
persistence, tool approval carrying call_id, attachments, search,
settings, MCP config, fork, projects, keychain-backed secrets
ui/ single-file frontend. Named ui/ rather than dist/ because it is
hand-authored source, not build output -- and the repo's .gitignore
excludes dist, which would have silently dropped the entire
interface from the commit.
Tests: 62 Rust, 8 pacing, 11 DOM click tests dispatching real MouseEvents.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The verbose-callers migration accidentally dropped the broad `except Exception`
branch (added in 2eacdcf79) that degrades an installed-but-uninitializable
memory backend to FileMemory. Without it, a registered backend that raises a
non-ImportError during init (mem0 missing credentials, MongoDB unreachable)
hard-crashes Agent construction. Restored to honour the fallback contract.
Co-authored-by: Mervin Praison <[email protected]>
yaml_loader.py documents verbose: true in its example agents.yaml, reads it,
and passes it to Agent() unconditionally with a default of False. Agent has no
verbose parameter, so every scheduled YAML agent raises TypeError on
construction -- whether or not the key appears in the YAML.
Verbosity now goes through output=, which is where it was consolidated.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Verbosity was consolidated into output=, but two shipped call sites inside
praisonaiagents were left behind and raise TypeError on construction:
compaction/memory_flush.py the pre-compaction agent could never be built
tools/delegation_tools.py every delegate_task call
Agent's rejection of unknown kwargs is deliberate and is left intact. What was
missing was guidance: verbose is accepted by six sibling classes, and Agent has
no verbosity parameter among its 41, so the answer is not discoverable from the
signature. The error now names output= as the destination.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The shared BotSessionManager._profile_namespace field was rewritten by every
routing handler, so overlapping turns for different tenants could recompute a
storage key from another tenant's profile after an await point — reading or
persisting one tenant's transcript into another's namespace (flagged by
CodeRabbit and Greptile).
Bind the resolved profile turn-locally in a per-task ContextVar inside chat()
(consumed from per-agent/instance staging and reset in finally), and propagate
that context into the load/save executor threads. Concurrent routed turns now
keep isolated namespaces and an unscoped route always fails closed.
Adds an overlapping-turns regression test.
Co-authored-by: Mervin Praison <[email protected]>
The gateway resolved RouteMatch.profile but never consumed it, so per-route
tenant isolation was inert and failed open — every route on a process shared
one memory store. Wire the resolved profile through the routing handlers onto
the session's storage key so memory/session state is namespaced per tenant,
and stays unscoped (fail-closed) when a route names no profile.
Co-authored-by: MervinPraison <[email protected]>
The lockstep extraction only recognised double-quoted literal msg_type
comparisons, so a dispatcher branch written enum-only (as ping/pong already
are, e.g. `msg_type == EventType.X.value`) would drift past it — the exact
silent registry/dispatcher divergence the test exists to catch.
Resolve every `EventType.<NAME>.value` operand through the enum to its wire
value, treat operands within one `in (...)` branch as aliases for a single
dispatch path (so the abort/MESSAGE_ABORT branch stays one method, not a
spurious `message_abort`), and add a positive control asserting the
enum-derived branch resolution still works.
Co-authored-by: Mervin Praison <[email protected]>
Providers send a tool-call id once and omit it on later argument chunks.
The DELTA_TOOL_CALL/TOOL_CALL_END branches minted a fresh UUID per chunk,
orphaning arguments from the invocation the client assembles. Track the
active tool call in EventBuffer and fall back to it. Also drop the
truthiness guard on these branches so a TOOL_CALL_END with an empty
tool_call payload closes the call instead of raising.
Co-authored-by: Mervin Praison <[email protected]>
Reuse the original request messages (system prompt, output constraints,
memory context) for the post-tool follow-up instead of chat_history alone,
which dropped the system turn. Bracket the follow-up answer with
REQUEST_START/LAST_TOKEN/STREAM_END so consumers see telemetry for the
answer, not just the tool-call round. Strengthen the regression test to
assert the sentinel flows through the streamed answer, proving the tool
branch ran.
Co-authored-by: Mervin Praison <[email protected]>
The streaming path collected tool calls, executed them, appended the results
to chat history -- and then ended. It never asked the model for the final
answer, so start(stream=True) yielded nothing while the identical
non-streaming call answered normally.
There was no error. The caller received an empty generator and could not
distinguish 'the model had nothing to say' from 'the answer was never
requested'.
The follow-up completion carries no tools, so the model answers from the
results it already has rather than calling another tool, which also bounds
the turn to a single round.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
resolve_required_scope() fails closed to ADMIN for anything it does not know,
which is the right default -- but four of the six message types the dispatcher
handles were unregistered, so switching the resolver on would have demanded
ADMIN for the connection handshake and locked out every client.
hello, join and leave are READ; abort is WRITE, since aborting a turn mutates
it exactly as sending one does.
Adds a lockstep test pinning the dispatcher's message types against the
registry, in the style that catches a runtime-only permission failure no
behavioural test can see.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The AG-UI adapter handled DELTA_TEXT, TOOL_CALL_START and TOOL_CALL_RESULT
and ended with a bare `return []`. StreamEventType has sixteen members, so
thirteen were discarded with no error and no log.
ERROR, RETRY, MODEL_FALLBACK and STREAM_UNAVAILABLE were among them: a
mid-stream provider failure produced a run that ended cleanly having never
told the client anything went wrong.
Every type now has an explicit disposition -- mapped, or listed in
NOT_WIRE_VISIBLE -- and an undeclared type raises rather than vanishing, so
adding a seventeenth member fails a test instead of dropping a frame in
production.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
CI test-core (subdirs) failed on test_every_advertised_preset_resolves with
"Mem0 API Key not provided" because the mem0 package is installed in that job
but no MEM0_API_KEY is set. Agent(memory="mem0") already promised a graceful
"Falling back to FileMemory" for missing optional dependencies (ImportError),
but an installed-yet-uninitialisable SaaS backend raised ValueError, which was
uncaught and crashed construction. Broaden the string-preset branch to honour
the same fallback contract for runtime init failures.
Pre-existing, unrelated to PR #4340 (gateway durability); no Agent params added.
Co-authored-by: Mervin Praison <[email protected]>
Address two valid review findings on the durability-degradation surface:
- Recovery clears degradation: a successful durable SqliteIdempotencyStore
build now clears any prior `durability:idempotency` degraded fact, so a
same-process rebuild (config hot-reload) that restores durability no longer
leaves health()/`gateway status` permanently reporting non-durable operation.
Mirrors the existing session-store recovery-clear path.
- Redacted operator-facing reasons: the idempotency and session fallback paths
no longer interpolate the raw store-init exception (which can embed backend
filesystem paths) into the degraded-owner `reason`. The raw exception stays in
the log only; the reported reason is an operator-safe, redacted string —
matching the documented contract on record_durability_degraded.
Tests: add recovery-clears-degradation test and a redaction assertion.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Gateway durable state was opt-in and lost silently. Now:
- build_idempotency_store is durable by default (SQLite when a state dir is
available); "memory" stays an explicit opt-in for tests/ephemeral runs.
- Gateway server default idempotency backend is durable when none configured.
- Session/idempotency store-init failures record a durability-degraded fact
(owner_kind="gateway", owner_id="durability:<component>") instead of a silent
logger.warning, reusing the existing DegradedCapabilityRegistry — no new core
protocol or Agent params.
- health() merges durability facts; `gateway status` prints a Degraded: section
with a redacted reason and a `fix:` hint so operators see non-durable runs.
Co-authored-by: MervinPraison <[email protected]>
- rules clear: constrain deletion to workspace-scoped CLI-created rules; no
longer unlinks discovered AGENTS.md/CLAUDE.md/PRAISON.md, global, git-root or
nested subdir rules (Greptile P1).
- context_files remote fetch: pin each connection to a validated public IP and
re-validate at connect time, closing the DNS-rebinding TOCTOU window while
preserving Host/SNI; _allow_local_urls opt-in bypasses pinning (Greptile P1).
- tests: add regressions for clear-scope preservation and rebind refusal.
Co-authored-by: Mervin Praison <[email protected]>
On a standalone `pip install praisonai-code` the subtree instruction hook,
`--instructions` resolution, and the `rules` command silently degraded because
their implementations were reachable only via the optional `praisonai_bot` /
`praisonai` wrapper packages.
- Add a resident, dependency-free `praisonai_code/integration/context_files.py`
(stdlib + `praisonaiagents.hooks` only) so subtree AGENTS.md loading and
`--instructions` glob/URL resolution work without the optional packages.
- Wire `run.py::_wire_subtree_context_hook` to fall back to the resident helper
after the optional bot/wrapper copies, removing the silent no-op.
- Give `rules list/add/clear` a native implementation backed by core
`praisonaiagents.memory.RulesManager`, delegating to the wrapper bridge only
when core is genuinely unavailable.
- Add standalone regression tests that block the optional packages.
Co-authored-by: MervinPraison <[email protected]>
Address two P1 races flagged by Greptile on PR #4336:
- executor.submit: reserve the admission slot synchronously (create+register
task before any await) so concurrent submits can no longer slip past the
max_queued ceiling. The QUEUED persist moves to the head of _execute_job
(before the concurrency semaphore), preserving the store-reflects-QUEUED
contract without a check-then-await race.
- router.submit_job: on JobQueueFull after save_if_absent persisted the job,
delete the stranded record so its idempotency key is freed and status/SSE
don't hang while /result returns 409. Cleanup is best-effort and never masks
the 503.
Co-authored-by: Mervin Praison <[email protected]>
Gap 1: JobExecutor now keeps a list of progress callbacks per job and
fans out to all of them, so concurrent SSE viewers of the same run each
receive updates; unregister removes only the given callback.
Gap 2: JobExecutor.submit rejects with JobQueueFull once max_queued
(default max_concurrent*10) is reached; the router maps this to a 503 +
Retry-After so floods get honest backpressure instead of unbounded task
growth.
Gap 3: AsyncAgentScheduler gains from_blueprint via a shared
build_from_blueprint helper in _base_scheduler; blueprint resolution now
lives in one place for both sync and async schedulers, and the async
variant dispatches via astart so it never blocks the event loop.
Co-authored-by: MervinPraison <[email protected]>
- chat_mixin: before_model/wrap_model_call mutations to temperature/tools now
flow into the retry core (were silently ignored)
- chat_mixin: after_model mutations on non-string responses are preserved via
_unwrap_model_response (previously raw object bypassed hook edits)
- chat_mixin: achat() now routes through the model middleware manager (was
bypassing before/after/wrap_model_call); sync chain bridged via executor
without nesting event loops, retry core extracted to
_achat_completion_with_retry_core
- knowledge: URL ingestion now captures bare string document ids (MongoDB)
Addresses CodeRabbit/Greptile review findings on PR #4334.
Co-authored-by: Mervin Praison <[email protected]>
- Task(skip_on_failure=True)/on_error="continue" now let a dependent task
run in degraded mode instead of being force-failed when an upstream
dependency fails, across every cascade path (agents async/sequential and
process sequential/asequential).
- Agent(hooks=[before_model/after_model/wrap_model_call]) now fire: model
calls route through MiddlewareManager.execute_model_call via a new
_get_model_middleware_manager, mirroring the existing tool-hook wiring.
- Knowledge.add() now ingests URL inputs (as its docstring documents) via
MarkItDown extraction instead of raising NotImplementedError.
Co-authored-by: Mervin Praison <[email protected]>
web_search and other tools declared in praisonaiagents' TOOL_MAPPINGS
resolve to None when their optional dependencies aren't installed. After
switching to ToolResolver.resolve(), these were incorrectly flagged as
'Unknown tool' errors, breaking test_agents_yaml_valid_fields_no_warnings.
Consult TOOL_MAPPINGS so such tools produce an install warning instead of
a hard error, while genuinely unknown tools still error.
Co-authored-by: Mervin Praison <[email protected]>
ConfigValidator._validate_tools called a non-existent resolve_tool()
method on the ToolResolver instance, raising AttributeError that was
swallowed by the broad except, downgrading every tool reference to a
generic warning. Unknown/misspelled tools were never reported as errors.
Use the actual instance method resolve(), which returns None on miss.
Co-authored-by: MervinPraison <[email protected]>
Address CodeRabbit review: _get_legacy_verbs() returned an empty set on
oracle import failure, which let implemented verbs (e.g. `thinking status`)
fall through to the bare-prompt rule and be forwarded to the modern `run`
engine as a paid LLM prompt — the exact #4327 regression the guard prevents.
Now fails closed via a static _LEGACY_VERBS_FALLBACK mirror, logs a warning,
and does not cache the fallback (so recovery is possible). Adds tests for the
fail-closed path, end-to-end routing under discovery failure, and a drift
assertion keeping the fallback in sync with the authoritative oracle.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Since 82f55c97b the unified dispatcher treated any first token that is not a
registered Typer command as free text and forwarded it to the modern `run`
engine, billing 29 implemented legacy verbs (thinking, install, persistence,
audio, …) to /v1/responses at ~2.5k tokens with exit 0.
Extract the legacy dispatcher's authoritative verb list into a reusable
LEGACY_SPECIAL_COMMANDS constant and have the unified dispatcher consult that
same oracle: an implemented legacy verb routes to legacy (its handler) before
the bare-prompt/LLM path. containers and vector-stores are withheld pending
#4322 so routing does not promote a fabricated success.
Co-authored-by: MervinPraison <[email protected]>
The `search` command was advertised in --help and registered as a real Typer
command, but was missing from get_command_names()'s routing oracle. This caused
`praisonai search "..."` to be misclassified as a bare prompt and billed as an
LLM call, contradicting its "no LLM generation" help text.
Adds `search` to the routed set and a parity regression test guaranteeing every
advertised command is also routed (guards the #2191 drift guarantee).
Co-authored-by: MervinPraison <[email protected]>
backends was added to _LAZY_COMMANDS but never mapped in COMMAND_CATEGORIES,
leaving test_every_registered_command_has_a_category failing and the command
without an explicit --help panel. Map it to Advanced.
Co-authored-by: Mervin Praison <[email protected]>
Vector store file ops now call the real litellm public API
(vector_store_file_create/list/delete) instead of the never-existent
create_vector_store_file names, and no longer swallow AttributeError into
fabricated success. Delete defaults to False, not True.
search() uses the shipped duckduckgo_tools.internet_search symbol (was the
nonexistent duckduckgo_search) and surfaces backend errors instead of a
permanent empty-but-successful result.
containers/container_files have no backend, so container_create and the
container file read/write/list functions now raise NotImplementedError
rather than inventing container IDs or returning empty results. CLI and MCP
adapters already wrap these in except Exception, so failures surface cleanly.
Co-authored-by: MervinPraison <[email protected]>
get_command set rich_help_panel but never command.name. typer_get_command
yields "" for a Typer(help=...) declared without name=, and collapses a
single-command app to that command's own name — so --help rendered 77/98
commands with a blank name and 3 with a wrong (untypeable) name. Pin
command.name to the registry key (the true typeable name) in get_command.
Co-authored-by: MervinPraison <[email protected]>
- _chunk.py: only re-wrap truly pure single-fence chunks in enforce_hard_cap;
mixed fence+prose or multi-fence chunks are hard-split as plain text so prose
is never rendered as code (Greptile P1).
- telegram.py: measure and chunk in the platform's declared UTF-16 unit so
emoji-heavy replies cannot exceed Telegram's 4096 UTF-16 cap (Greptile P1).
- tests: mixed-fence non-corruption + UTF-16 length coverage.
Co-authored-by: Mervin Praison <[email protected]>
Part 1: split_media_from_output stripped every blank line from every
Telegram/Slack reply, destroying load-bearing separators, paragraph
breaks and clean chunk boundaries. Now only excess blank runs left by
removed MEDIA lines are collapsed; single blank-line separators survive.
Part 2: chunk_message emits an over-cap code fence by design and no
caller re-checked the platform cap, so a pure/oversized fence raised
"message too long" and lost the whole reply. New enforce_hard_cap()
splits any over-cap chunk (re-balancing ``` on split fences) and is
applied in every _send_long_message (telegram/slack/discord).
Fixed together so restoring blank lines cannot regress fence delivery.
Co-authored-by: MervinPraison <[email protected]>
- Remove the dead /sethome registry entry: it was published to every
adapter's menu but implemented on none, so drop the false advertisement.
- Telegram: on a slash-command miss, dispatch the raw text to the agent
instead of ending silently, matching the Slack reference and the code's
own docstring.
- Discord: remove the bare terminal return in the on_message command block
so an unknown/unimplemented slash command falls through to agent dispatch
(like Slack) rather than a silent no-op.
- _session.reset()/reset_all(): clear _model_overrides so /new actually
remedies a bad or unavailable /model instead of every later turn failing
silently at the provider.
Co-authored-by: MervinPraison <[email protected]>
Address final-review findings on #4316:
- _export_qc_cfg no longer force-zeroes script thresholds when a caller sets
script_range/script_drop/script_flag, so a documented
qc_cfg={"script_range": [...]} re-enables the target-script check for other
languages instead of silently passing wrong-script rows. English default
(thresholds=0) is unchanged when no script key is given.
- Add test for the script_range-only re-enable path (kept for Latin, dropped
for Tamil).
- README: validate's atomic --out write is shared with generate/dedup, and
document that any script key re-enables the export QC check.
Co-authored-by: Mervin Praison <[email protected]>
Explicitly chain the translated typer.Exit from the caught SystemExit to
resolve Ruff B904 and preserve the original traceback.
Co-authored-by: Mervin Praison <[email protected]>
The legacy dispatcher's `if args.command:` chain had no `train` case, so
`praisonai-train llm <dataset>` fell through to the bare-prompt handler and
forwarded the literal word "train" to an LLM as a chat prompt — the real
training block was never reached and no trainer subprocess launched.
- Add a `train` guard in main()'s command chain so it reaches the dedicated
training block (which reads args.dataset/args.model and dispatches
praisonai_train.train.llm.trainer).
- Propagate a non-zero SystemExit from the router in train_llm so missing-dep
failures no longer exit 0 (CI gating on $? now sees them); clean exits still
return 0.
- Add a regression test that drives the real router (asserts the trainer is
dispatched and handle_direct_prompt is never called) plus exit-code tests.
Co-authored-by: MervinPraison <[email protected]>
- from-trials --qc: default export QC config disables the Tamil script-purity
check, since agent trajectories are English by construction (kept dedup,
boilerplate/refusal, length, diversity). qc_cfg still re-enables it.
- Surface the actual QC drop reasons: ExportSummary gains skipped_qc/qc_drops so
no row vanishes unaccounted, and from-trials names the reasons on the zero-row
error instead of guessing.
- validate: reuse the in-module tempfile+os.replace helpers (mirrors dedup/
generate) with a zero-kept guard, so an --out that aliases the input is never
truncated and a zero-kept run exits non-zero leaving any existing file intact.
- Document the Tamil script_range default in README and validate --help.
Co-authored-by: MervinPraison <[email protected]>
- server.py: extension-engine termination now uses the per-session agent's
max_steps (forwarded via start_session) instead of the server default, so
`browser run --max-steps` actually bounds the run (Greptile P1).
- browser.py: playwright/hybrid engines now warn when CDP-only options
(--vision/--screenshots/--record-video/--max-retries/--no-record) are set
rather than silently discarding them; corrected the run --help "Features"
block to state these require --engine cdp specifically (Greptile P1).
Co-authored-by: Mervin Praison <[email protected]>
- knowledge.query: forward `limit` to Knowledge.search (was dropped)
- a2a.send: forward `task_id` via kwargs (was discarded)
- realtime.send: map message to `data` with real event_type (was passed as event_type)
- add regression tests for the three semantic bindings
Co-authored-by: Mervin Praison <[email protected]>
- Send max_steps in start_session wire frames on the extension path
(server.py already reads it, so this was a pure missing-field fix)
- Pass record_video=record_video to run_cdp_only in _run_alternative_engine
so --record-video on cdp actually records instead of only billing gpt-4o
- Warn when engine-only options (--vision/--screenshots/--record-video/
--max-retries/--headless/--extension/--no-record) are set on the default
extension engine, instead of silently discarding them
- Clarify --url is context-only on the extension path and show max steps
- Fix run --help "Features" caveat and add --engine to screenshots example
Co-authored-by: MervinPraison <[email protected]>
A CDP click/type/clear_input on a selector matching nothing returned success
unconditionally, so run()'s retry loop broke on first success and --max-retries
plus all fallback-selector strategies never fired. The injected JS now returns
whether the element was found and a miss yields success=False with an error,
making the existing alternative-selector retry strategies reachable.
Co-authored-by: MervinPraison <[email protected]>
Coerce unknown deploy keys to str before sorted()/join() so a non-string
YAML key raises a clean ValueError instead of TypeError, and guard that
the deploy section is a mapping. Adds regression tests.
Co-authored-by: Mervin Praison <[email protected]>
status() and destroy() previously fell back to the ambient az
subscription when subscription_id was unset, risking wrong-subscription
inspection/deletion. Both now return their structured failure result
before issuing any az command, mirroring deploy()'s guard.
Also fixes test_azure_provider_deploy_failure to supply a
subscription_id so it reaches subprocess.run, and adds no-subscription
tests for status() and destroy().
Co-authored-by: Mervin Praison <[email protected]>
27 of 85 advertised praisonai-mcp tools could not succeed for any input:
their adapters called core/wrapper APIs that don't exist or have different
signatures, failing at attribute lookup or argument binding before any I/O.
24 returned isError:false and 9 reported an installed module as "not available".
Rebindings against real core:
- knowledge.*: query->search, list->get_all, stats->get_corpus_stats,
clear->reset, add(source) (dropped nonexistent source_type)
- hooks.*: get_default_registry().list_hooks() (was get_hook_manager)
- session.*: get_default_session_store() (was SessionManager)
- tools.*: list_tools/get_tool (was get_available_tools/get_tool_info/search_tools)
- eval.*: AccuracyEvaluator/PerformanceEvaluator (was AccuracyEval/PerformanceEval)
- workflow.run/run_file: AgentsGenerator(..., config_list=[])
- workflow.auto: AutoGenerator(topic, pattern).generate()
- audio.transcribe(audio=), audio.speech(text=), guardrails.check(content=),
ocr(document=), a2a.send(target_agent=), containers.file_read/write(path=),
skills.load(skill_name=), realtime.send(event_type=)
- resources: memory/sessions -> session store, knowledge/sources -> get_all
_handle_tools_call now sets isError:true when a handler returns an
"Error: ..." string, matching the convention already used for adapters
that raise.
Tests bind against real core (not hand-written fakes), so a future core
rename the adapter doesn't track fails here.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: MervinPraison <[email protected]>
Bring DockerSandbox policy validation to parity with SubprocessSandbox
where it is semantically meaningful for a container:
- Enforce allowed_paths (opt-in) alongside blocked_paths, so a path
outside configured roots is refused before dispatch.
- Route run_command through the shared build_argv helper so a shell=True
payload is split by policy_scan_parts and its paths are scanned,
instead of walking an opaque `sh -c "..."` argv.
Deliberately does NOT copy SubprocessSandbox's allow_subprocess shell
block: Docker always dispatches via `sh -c` inside the container, which
is the isolation boundary that clause approximates on the host. Applying
it would refuse every command and defeat the backend rather than harden
it.
Adds regression tests for allowed_paths (in/out) and shell-payload path
scanning. All host-side + Docker filesystem tests pass.
Co-authored-by: Mervin Praison <[email protected]>
A misspelled field/section key in agents.yaml was silently discarded by
pydantic's default extra='ignore', so `deploy validate` reported valid while
deploying with defaults (e.g. a typo in cluster_name deploying to a new ECS
cluster). Set extra='forbid' on the deploy input models, reject unknown
top-level deploy sections in schema validation, surface cluster_name in the
human-readable validate output, and make the docker `push: true` path reach the
existing "no registry" error instead of silently skipping the push.
Co-authored-by: MervinPraison <[email protected]>
Azure Container Apps deployments read subscription_id only to echo it in
plan(); it was never passed to az, so every resource was created/destroyed in
the ambient default subscription. Mirror the GCP provider:
- append `--subscription {subscription_id}` to all six az invocations across
deploy/status/destroy/doctor via a _subscription_args() helper
- fail fast in deploy() when subscription_id is unset (matches GCP's project_id
guard)
- check_azure_cli() now compares the ambient subscription against the configured
one and warns on mismatch
Co-authored-by: MervinPraison <[email protected]>
DockerSandbox accepted a SecurityPolicy and enforced none of it, so moving
to the tier documented as stronger silently dropped every command-level
control. Mirror the tested SubprocessSandbox enforcement:
- Validate commands against blocked_commands / allowed_commands / blocked_paths
in run_command before dispatch.
- Truncate host-side communicate() output to max_output_size in run_command
and execute, so a runaway print in the container cannot exhaust the host
process.
Co-authored-by: MervinPraison <[email protected]>
Extends the OpenAI-native /stop fix to every synchronous same-run
re-dispatch that previously dropped the token, closing the Greptile P1
"nested completions drop cancellation":
- reflection + regeneration _chat_completion calls (chat_mixin.py)
- internal recursive redispatches: context-compression retry,
model fallback, retryable-error backoff (chat_mixin.py)
- guardrail-retry regeneration via _apply_guardrail_with_retry
(agent.py + its sync call sites in chat_mixin.py)
All hops just forward the existing cancel_token param (defaults None),
so behaviour is unchanged when no token is set. No new Agent params,
modules, or exports.
Co-authored-by: MervinPraison <[email protected]>
The default (non-custom) LLM path dropped the cancel token: chat_mixin
threaded llm_kwargs['cancel_token'] only inside the custom-LLM branch,
while the OpenAI-native else branch called _chat_completion(...) which
had no cancel_token parameter. The token never reached OpenAIClient,
so the between-iteration cancel checks were never armed and /stop
reported success while remaining tool calls kept executing.
Thread cancel_token symmetrically through the four sync hops that
already existed: _chat_completion -> _chat_completion_with_retry ->
_execute_unified_chat_completion -> _unified_dispatcher.chat_completion,
matching the custom-LLM branch. Defaults to None so no caller behaviour
changes.
Co-authored-by: MervinPraison <[email protected]>
The async idempotency veto only scanned self.tools, so a task-scoped tool
supplied via tools_override (and never registered on the agent) missed its
restart_safe=False / idempotent=False declaration and was retried, duplicating
its side effect. _tool_declares_not_idempotent now accepts the effective
tools_override set (searched first, mirroring _execute_tool_async_impl
resolution) while defaulting to None for the unchanged sync call sites.
Also tightens the test helper to catch only the expected RuntimeError /
ToolExecutionError instead of a blanket Exception, and adds an override-only
regression test.
Co-authored-by: Mervin Praison <[email protected]>
Adapter-backed get_all_memories() returned records keyed by `memory_type`
(SQLite) or `type` (in-memory), diverging from the legacy path's `type`
field and breaking callers that read record["type"]. Normalize adapter
results to always populate `type` while retaining adapter-specific keys.
Strengthen regression tests: assert both colliding tiers are erased on
untyped deletion, and assert the public `type` field is present on
adapter-backed records.
Co-authored-by: Mervin Praison <[email protected]>
_execute_tool_async_with_retry never consulted the idempotency
declaration, so achat()/astart() re-ran an effectful tool marked
restart_safe=False up to max_attempts times. Add the same veto the
sync loops use (tool_execution.py:884 and :2115) plus an async test.
Co-authored-by: MervinPraison <[email protected]>
Address reviewer findings on PR #4296:
- Data-loss guard (CodeRabbit): derive WarmSession.flushed from a per-key
_flushed marker set only after a completed store write, not from
_store presence. A caught/partial persist now leaves the cache unflushed
so the pressure sweep never evicts the only current copy. Marker cleared
in _save_history (pre-write), _soft_evict, and reap_stale.
- Nested cgroup (CodeRabbit): resolve the process's own cgroup path from
/proc/self/cgroup and probe its v2/v1 memory limit first, falling back to
the filesystem root so a nested container's lower ceiling is honoured.
- Tests: add failed-persist skip and nested-cgroup coverage (15 total pass;
core planner 16 and gateway session suites still green).
Co-authored-by: Mervin Praison <[email protected]>
get_all_memories() selected from legacy short_mem/long_mem tables that no
adapter creates, so it swallowed OperationalError and returned [] for every
provider. Delegate to the active adapter, mirroring delete_short_term/
reset_short_term.
delete_memory(id) with no memory_type short-circuited on the first tier that
reported a hit. Since the SQLite adapter gives each tier its own AUTOINCREMENT
sequence, ids collide across tiers, so it erased an unrelated short-term row
and left the caller's target in place while reporting success. Delete from
both tiers instead of short-circuiting.
Co-authored-by: MervinPraison <[email protected]>
The core planner plan_pressure_evictions() plus the WarmSession /
MemoryPressureProtocol contracts had no runtime consumer, so an
always-on multi-session gateway had no way to reclaim warm-cache memory
under pressure and climbed to OOM.
This adds the missing wrapper-side enactor and a concrete reader:
- BotSessionManager.warm_sessions(): snapshots warm caches as core
WarmSession facts (LRU last_activity, in_flight, flushed).
- BotSessionManager.sweep_under_pressure(): reads the real cgroup budget/
RSS, calls the core planner, and soft-evicts the coldest rebuildable
caches LRU-first, re-sampling RSS to shed the minimum. No-op without a
cgroup limit, preserving today's behaviour.
- BotSessionManager._soft_evict(): drops only the in-memory history; the
durable store copy remains and is rebuilt on the next turn (lossless).
- Per-turn in-flight tracking so a live turn's cache is never evicted.
- gateway/memory_pressure.py CgroupMemoryPressure: stdlib-only concrete
MemoryPressureProtocol (cgroup v2 memory.high/max, v1 fallback,
/proc/self/status RssAnon), degrading to no-op when unreadable.
Flushed (store-backed) caches only are evicted; in-flight and unflushed
caches are always skipped. Backward-compatible: no eviction unless the
gateway calls sweep_under_pressure on its periodic tick.
Co-authored-by: MervinPraison <[email protected]>
Closes two P1 gaps in the ack scope gate (Greptile #4293):
- Gateway-hosted Telegram handler acked on `_ack.enabled` alone, bypassing
ack_scope so ambient group chatter still got a ⏳ reaction. Now routes
through should_ack_message like the standalone adapter.
- Slack classified a thread reply (no fresh @mention, mention_required off)
as ambient, silently dropping its ack. thread_ts now counts as engaging
the bot so the reply is acked under group-mentions.
Adds 3 TestAckScopeGating cases for the gateway wiring and Slack thread
classification.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Addresses Greptile P1 findings on PR #4292:
- GatewayApprovalBackend now threads authorized_reviewers into register(),
so per-request reviewer custody is actually bound on the production path
(previously silently dropped, leaving every request unbound).
- _resolve_operator_identity prefers a non-secret X-Operator-Id header before
the shared-token digest, so a proxy/SSO layer can distinguish individual
operators sharing a gateway token in the audit trail.
Adds a regression test asserting the backend binds custody end-to-end.
Co-authored-by: Mervin Praison <[email protected]>
Add a closed AckScope (off | direct | group-mentions | group-all | all)
to AckReactor with a should_ack()/should_ack_message() gate, and a
BotConfig.ack_scope key (default group-mentions). Adapter call sites
(telegram/discord/slack/whatsapp/signal/linear) now derive direct/mention/
ambient context from the existing BotMessage and only ack messages the
scope covers, instead of reacting to every non-command message.
Co-authored-by: Mervin Praison <[email protected]>
The gateway resolve boundary now captures the resolving principal on the
durable audit trail (ApprovalDecision.approver) and allow-always grant,
replacing the constant "gateway"/"gateway:human" attribution. Resolution
gains an optional resolver id, threaded from the authenticated HTTP request
context (derived as a non-secret hash handle of the operator token).
An approval may optionally be bound to a set of authorized reviewers;
resolve() rejects a resolver not in that set (re-inserting the request so an
authorised reviewer can still resolve it) while preserving first-answer-wins
via the atomic pop. The binding is durable across restarts. Core
ApprovalRequest gains an optional authorized_reviewers field (the only core
touch). All defaults keep today's behaviour.
Co-authored-by: Mervin Praison <[email protected]>
The Signal poll loop refreshed inbound-liveness on every successful poll
batch, including successfully-empty ones. A silently half-open transport
returns [] forever without raising, so this kept health.last_activity
fresh and defeated the existing evaluate_channel_health STALE_SOCKET seam,
leaving a "deaf" channel reported HEALTHY with no operator signal.
Only note inbound activity when the bridge actually delivered an envelope,
so a stalled poll loop becomes detectable through the existing health seam
(no new module, API surface, or config knob).
Co-authored-by: Mervin Praison <[email protected]>
Address Greptile P1s on AsyncMongoDBStateStore:
- async_hdel now returns only the count of fields actually present and
removed (StateStore contract "Returns count deleted"), matching the
Firestore/DynamoDB document-store backends instead of over-reporting.
- async_hset/async_hdel treat fields as opaque top-level keys via a
$mergeObjects pipeline / read-compute-write, so a dotted field like
"a.b" round-trips through hget/hgetall/hdel instead of being coerced
into a nested MongoDB path.
Co-authored-by: Mervin Praison <[email protected]>
- AsyncMongoDBStateStore: implement the 7 missing StateStore abstract
methods (keys, ttl, expire, hget, hset, hgetall, hdel) plus async twins
so the class is instantiable and every async_mongodb/motor/mongodb_async
factory alias boots instead of raising TypeError.
- db/adapter.py: rename the 6 async state-store call sites from the
non-existent aset/aget to async_set/async_get so async persistence hooks
take the native coroutine path instead of silently falling back to sync.
- agents_generator.safe_format: delegate to core
BaseFrameworkAdapter._format_template (single source of truth) so wrapper
and core cannot drift; fixes latent TypeError on non-string (None) input.
Co-authored-by: MervinPraison <[email protected]>
Closes the Greptile/CodeRabbit approval-clone coverage gap: the prior test
only inspected the original agent's private _approval_config and never
constructed a clone. Add a clone round-trip test asserting the served clone
reconstructs the same approval config and enforced _perm_deny set, plus a
negative test that a plain agent and its clone stay policy-free.
Co-authored-by: Mervin Praison <[email protected]>
clone_for_channel() rebuilds the agent from getattr(self, '_guardrails_config',
None) and getattr(self, '_approval_config', None), but neither attribute was
ever assigned to the instance -- both were locals inside __init__. The getattr
therefore always returned None and every served clone (one per gateway channel,
one per HTTP invocation) ran with no output guardrail and lost its approval
policy. Persist both resolved values on the instance so the clone can read them.
Co-authored-by: MervinPraison <[email protected]>
Regression from #4257: `_tool_declares_not_idempotent` fell back to the
shared `MUTATING_TOOLS` name registry, silently dropping the retry from
any *undeclared* tool that happened to share a name with an entry
(`write_file`, `store_memory`, `mkdir`...). Key on the explicit
declaration only, and consult the process-global registry the way
`_is_tool_idempotent` already does when `allow_global_tools` is set.
Co-authored-by: MervinPraison <[email protected]>
- Return False for non-dict verdicts (e.g. [], "allow", null) to avoid
AttributeError and preserve fail-closed behavior (CodeRabbit)
- Set ToolResponse.result to the denial message since the agent tool
executor returns result and discards error, so denials reach the model
instead of a null result (Greptile)
- Add non-object JSON regression test; update block test assertion
Co-authored-by: Mervin Praison <[email protected]>
- Re-add Python-style single-quoted list support via ast.literal_eval
(safe literal-only eval), fixing a regression where ['a','b'] collapsed
into a single loop item.
- Anchor JSONDecoder.raw_decode with the idx argument instead of slicing
text[start:] to avoid per-bracket copies.
- Cap decode attempts (and keep the 1M-char guard) so deeply nested
bracket input can no longer cause O(n^2) parsing time or a crash
(RecursionError now caught alongside JSONDecodeError).
- Add regression tests for single-quoted lists and adversarial input.
Co-authored-by: Mervin Praison <[email protected]>
Adds an optional, provider-neutral example that gates command-bearing
tools through HOL Guard at the existing wrap_tool_call boundary. The
policy is fail-closed: the wrapped tool only runs on an explicit allow
verdict; block/unknown/malformed/timeout/CLI-failure short-circuit with
zero downstream execution. Includes a regression test suite proving a
denied command never reaches the tool executor.
Co-authored-by: kantorcodes <[email protected]>
Delete the unused ServerRegistry class and _get_default_server_registry()
helper. These were never wired into the live launch path; server state
lives in module-level dicts in execution_mixin.py, torn down by
cleanup_launch_registration(). Also update the now-stale comment that
referenced the removed class.
Co-authored-by: MervinPraison <[email protected]>
- reject import payloads newer than PORTABLE_VERSION (reported, not applied)
- persist imported sessions verbatim via _save_imported_session (bypass
destination retention/active_window so a valid larger export is not truncated)
- copy metadata before clearing live fields so the caller payload is not mutated
- write CLI exports to a temp file + atomic os.replace (never corrupt an
existing backup on failure; nonzero exit on error)
- add tests for the above
Co-authored-by: Mervin Praison <[email protected]>
- Reject non-finite/non-increasing bucket bounds before storing (prevents
render_prometheus() failures)
- Raise ValueError on conflicting bucket layouts for an existing metric name
instead of silently ignoring later overrides
- Reject caller-supplied reserved 'le' label to avoid duplicate le samples
- Fix snapshot() to emit name_count{labels}/name_sum{labels} matching the
Prometheus renderer
- Rename _HistData sum param to avoid shadowing builtin (ruff A002)
- Add tests for bucket conflicts, le rejection, validation, and label ordering
Co-authored-by: Mervin Praison <[email protected]>
Extend the session-store contract with a store-agnostic portability surface so
a gateway operator can back up, move, or restore live conversation state:
- core: add PortableSessionStoreProtocol + ImportReport to session.protocols
- core: implement export_session / export_all / import_sessions on
DefaultSessionStore (SqliteSessionStore inherits it), reusing the existing
SessionData.to_dict/from_dict shape. Export is lineage-aware; import is
hardened (size cap, duplicate guard, malformed-record skip) and resets live
routing/activity fields so restored state is inert until re-bound.
- core: export the new symbols from session/__init__.py
- wrapper: add `praisonai gateway sessions export/import` CLI + preflight
helpers over the gateway's own default store.
Co-authored-by: MervinPraison <[email protected]>
Add a dependency-free histogram metric type to GatewayMetrics so the
gateway /metrics endpoint exposes latency distributions (buckets, _sum,
_count) for SLOs and slow-turn diagnosis, alongside the existing
counters and gauges.
- observe(name, seconds, buckets=..., labels=...) records durations
- timer(name, ...) context manager times its body and records it
- render_prometheus() emits _bucket/+Inf/_sum/_count histogram lines
- snapshot() includes a "histograms" section
- default buckets cover sub-second to multi-second turns
Co-authored-by: MervinPraison <[email protected]>
Address two P1 review findings on the gateway saturation telemetry:
- LoopWatchdog: anchor the lag clock to the oldest still-unacked probe so a
stall spanning several probe intervals is no longer understated (each fresh
probe was overwriting the send-time). The ack consumes the anchor so the
next probe re-anchors from a caught-up loop.
- OutboundQueue.try_depth_snapshot(): non-blocking (pending, dead_letter)
counts that yield None when the writer lock is contended, and wire
_compute_pressure() to reuse last-known depths instead of blocking. Health
and /metrics run on the event loop and must never wait on the outbox lock.
Adds regression tests for the anchoring invariant and the non-blocking snapshot.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Mervin Praison <[email protected]>
The always-on gateway enforces back-pressure (admission ceiling + fair
wait-queue, durable outbox/DLQ, event-loop wedge watchdog) but exposed no
saturation telemetry — health() reported liveness only, so the first signal
of overload was dropped work.
Core (praisonaiagents) owns the shape + a pure classifier, mirroring the
ReloadStatus precedent:
- HealthPressure frozen dataclass + deterministic evaluate_pressure() helper
- LoopWatchdog now reads back its continuously-measured scheduling lag
(last_lag_ms), the same signal the wedge check already uses
Wrapper (praisonai_bot) reads facts already in hand and fills the block:
- health() gains a "pressure" block; /metrics gains admission/backlog/lag gauges
- OutboundQueue.dead_letter_count() surfaces the permanent-failure tier
Additive observability only — no new config keys, no new endpoint.
Co-authored-by: MervinPraison <[email protected]>
Save the prior tool_call/llm_content callbacks and restore them on cleanup
under the core callbacks lock with an identity check, instead of blindly
overwriting and deleting the process-global slots. This mirrors
praisonaiagents' own register/restore pattern so another in-process
consumer that owns these slots keeps receiving events after a TUI turn.
Addresses Greptile P1 (global callback ownership lost).
Co-authored-by: Mervin Praison <[email protected]>
The default interactive TUI (async_tui.py) only registered the tool_call
display callback, so intermediate assistant text was rendered as a single
whole-response append after the worker thread joined. Register the core
llm_content callback (already emitted per LLM step) to push narrative into a
live assistant message as the turn progresses, and reuse that streamed
message as the finalised turn to avoid a duplicate append. Read-only review
turns keep their single-summary behaviour. No new core surface; wrapper-only.
Co-authored-by: Mervin Praison <[email protected]>
- api/call.py: the lazy-import refactor dropped the ``request: Request`` /
``websocket: WebSocket`` annotations, so FastAPI treated ``request`` as a
required query param and 422'd the ``/`` route. Stamp the real classes onto
the handlers in _register_routes (build time, where fastapi is already
imported) — import stays cheap, route injection restored.
- cli/_shim.py: skip ``None`` placeholders in the eager walk_packages loop so a
known-absent submodule can't poison the old dotted path before _AliasFinder
resolves it lazily (mirrors the existing guard in the first pass).
- test_wrapper_layer_regression.py: force PRAISONAI_ALLOW_LOOP_BLOCKING empty
around the strict assertion so an ambient opt-in can't mask the RuntimeError.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
The lazy-by-default shim in cli/_shim.py broke module identity across the
old/new dotted paths (guarantee #1 in its own docstring): importing the
old name first (e.g. `from praisonai.mcp_server.cli import ...`, as the
tests do) re-entered _AliasFinder and double-loaded the moved file,
yielding two distinct module objects. That made mock.patch("<old.path>")
target a different object than the running code (imported via the new
name), causing the test-core (cli/subdirs) and smoke CI failures.
Restore eager pre-registration as the DEFAULT (it was already lightweight:
optional heavy submodules that fail import are logged and left for lazy
resolution). Keep an explicit opt-out via PRAISONAI_SHIM_LAZY=1 for
callers that don't rely on cross-path module identity.
Also make api/call._load_local_tools() idempotent (clear before repopulate)
so repeated build_call_app(load_local_tools=True) builds no longer
accumulate duplicate tool definitions in the shared registry (greptile).
Co-authored-by: MervinPraison <[email protected]>
Three lightweight, backward-compatible wrapper fixes:
1. cli/_shim.py: gate the eager walk_packages pre-import behind
PRAISONAI_SHIM_EAGER (lazy by default via _AliasFinder) and log
previously-swallowed submodule ImportErrors instead of silently
skipping them.
2. api/call.py: make heavy deps (fastapi, twilio, uvicorn, pyngrok,
websockets, rich) lazy-imported; build the FastAPI app via a
build_call_app() factory plus a lazy module __getattr__ for `app`;
move the import-time tools.py filesystem scan + user-code execution
into a server-entry-point path so importing the module has no side
effects.
3. _async_bridge.run_sync_or_offload: refuse to block a running event
loop by default (opt-in via PRAISONAI_ALLOW_LOOP_BLOCKING), steering
async callers to arun_sync_or_offload; PraisonAIAdapter.run uses
run_sync on the plain-sync path and raises a clear error from a
running loop.
Updated the corresponding wrapper regression test to the new strict
contract.
Co-authored-by: MervinPraison <[email protected]>
The sync workflow() path (lines 1502/1504) is duplicated code, edited
independently from aworkflow(). The existing suite drove only aworkflow(),
so a whitespace regression in the sync branch would leave it green.
Adds _run_sync driver + parametrized sync retry/control tests. Verified
they fail without .strip() and pass with it.
Co-authored-by: Mervin Praison <[email protected]>
Decision task verdicts were routed via result.raw.lower() and pydantic.decision.lower() at eight sites without strip(). Raw model text routinely carries a trailing newline, so the condition lookup missed and a miss was treated as exit-condition-met: the workflow finished silently with the rejected output (no retry, no validation feedback, no error). This adds strip() at all eight sites so a padded verdict routes exactly like a clean one, matching validate_decision_string which already lowers and strips.
Co-authored-by: MervinPraison <[email protected]>
The retry veto only read the raw `idempotent` attribute, missing the public
`restart_safe=False` contract used by @tool (FunctionTool) and BaseTool. Such
effectful tools remained retryable in both loops, duplicating side effects.
Fall back to `restart_safe` when `idempotent` is unset; add regression tests
for @tool and BaseTool declarations.
Co-authored-by: Mervin Praison <[email protected]>
A tool declaring idempotent=False was still re-driven up to max_attempts
times by both retry loops, duplicating side effects (send a second email,
charge twice). Both loops now veto the retry when a tool *explicitly*
declares itself non-idempotent, via a new tri-state helper
_tool_declares_not_idempotent that keys on the explicit declaration only,
leaving undeclared/declared-safe tools' retry behaviour unchanged.
Co-authored-by: MervinPraison <[email protected]>
- _extract_llm_response_content now inspects message.refusal (carried
independently of finish_reason) and warns without logging refusal text
- async chat_history test now asserts result == "" and an empty assistant
message, closing gaps flagged in review
Co-authored-by: Mervin Praison <[email protected]>
The streaming auto-detect block in _chat_completion caught every
non-LLMError exception and fell back to a non-streaming retry. Tool
failures during the streaming attempt were therefore run again by the
fallback, executing the tool twice and potentially masking the original
typed failure. Re-raise ToolExecutionError before the generic fallback,
mirroring the guard in the main completion block.
Also tighten the regression tests to assert the failing tool actually
executed (the tool executor retries a failing tool, so every attempt is
verified to target the same recipient).
Co-authored-by: Mervin Praison <[email protected]>
_extract_llm_response_content ended in return str(response), reached whenever
a well-formed response carried no content and no tool_calls (content filter,
refusal, or finish_reason=length with zero output tokens). achat() used this,
so the user's answer became the raw ChatCompletion repr, persisted into
chat_history and replayed to the model.
Now a cleanly-parsed-but-empty message returns "" (matching the sync path),
logging a warning when finish_reason is unexpected. str(response) is reserved
for genuinely unparseable responses where the repr is a diagnostic.
Co-authored-by: MervinPraison <[email protected]>
Prefix the unused _agent() tuple element with an underscore to satisfy
Ruff RUF059, as flagged in review.
Co-authored-by: MervinPraison <[email protected]>
The sync reflection handler incremented the retry counter and continued
unconditionally on a parse error, so a persistently unparseable reflection
(e.g. an OpenAI structured-output refusal where message.parsed is None)
looped forever at two LLM calls a turn and chat() never returned. Mirror the
async bound at chat_mixin.py so the loop stops at max_reflect and returns the
un-reflected answer.
Co-authored-by: MervinPraison <[email protected]>
Add an explicit `except ToolExecutionError: raise` guard before the blanket
`except Exception` in `_chat_completion`, mirroring the existing
`BudgetExceededError` escape hatch. Previously a `ToolExecutionError` raised by
the sync tool executor was caught by position and re-labelled as `LLMError`,
causing chat() to return None, the tool's message to be fed to the LLM error
classifier (so a tool error resembling a rate limit re-drove the model with no
tool result), and confident answers generated with no tool result.
Co-authored-by: MervinPraison <[email protected]>
- Resolve RLIMIT_* via getattr so POSIX platforms missing a constant skip
that cap instead of raising AttributeError before subprocess launch
- Add RLIMIT_CPU (CPU-seconds from timeout) so CPU-bound sandbox code is
stopped by the kernel rather than only the wall-clock timeout
- Check cancel token before AND after rate-limiter acquire in both sync
chat() and async achat() backend branches, so pre-cancelled requests do
not block on the limiter or consume capacity
Co-authored-by: Mervin Praison <[email protected]>
The overflow pointer advertises read_file/grep on the spilled artifact, but
those tools reject out-of-workspace paths. When PRAISONAI_TOOL_OUTPUT_DIR is
unset, spill into a workspace-local .praisonai_tool_output dir so the omitted
output stays retrievable (fixes Greptile P1 on #4247).
Co-authored-by: Mervin Praison <[email protected]>
- Rewrite success path now surfaces the "Applied N changes" receipt that
sg writes to stderr, instead of reporting the failure value
"No changes made" after a real, destructive rewrite.
- Confine path/rule_file to the workspace via path_safety.resolve_within_root.
- Add a `--` argv terminator so a dash-leading path is never parsed as a flag.
- Bound search/rewrite/scan output via _output_overflow.bounded_with_pointer.
Co-authored-by: MervinPraison <[email protected]>
1. Sandbox ResourceLimits (memory/CPU/process/fd/disk caps) are now applied
via setrlimit in a POSIX preexec_fn in _execute_code_sandboxed, instead of
only honouring the wall-clock timeout. Non-POSIX platforms log a warning.
2. Sync chat()'s managed-backend branch now applies the rate limiter and
cooperative cancellation check before delegating, matching achat().
3. _format_tools_for_litellm now imports the canonical GEMINI_INTERNAL_TOOLS
constant instead of a hand-duplicated set.
Co-authored-by: Mervin Praison <[email protected]>
The malformed-JSON cases in test_tool_call_parsing encoded the old buggy
behaviour (args -> {}), which silently ran tools with wrong arguments and
reported success. Defect 1 of #4241 replaces {} with a distinct
_TOOL_ARGUMENTS_PARSE_FAILED sentinel. Update the test to assert the
sentinel (not weaken it) so it keeps guarding that malformed args are
surfaced as retryable errors instead of dispatched.
Co-authored-by: Mervin Praison <[email protected]>
The new sanitise tests embedded realistic secret-shaped literals (Stripe
sk_live_/sk_test_ keys, an AWS-style secret value) as fixtures, which
GitGuardian flagged as hardcoded secrets and blocked CI. Construct those
fixtures by concatenation at runtime so no scannable secret literal lives
in source, while the redaction regexes are still exercised against a
matching string. No change to what the tests assert.
Co-authored-by: Mervin Praison <[email protected]>
The hard-limit check and failure-path fallback in _apply_context_management
counted only message-history tokens. A large system prompt or tool schema
could push the request over the model window while the history alone stayed
under 95%, so truncation was skipped and an over-limit request was sent.
Both paths now add system-prompt + tool-schema tokens via a new
_estimate_request_overhead_tokens helper, and the emergency target reserves
that overhead so the whole request lands near 80% of the window. Adds
regression tests covering the fallback with an oversized system prompt.
Addresses CodeRabbit and Greptile review on PR #4242.
Co-authored-by: Mervin Praison <[email protected]>
os.replace(tmp, out_path) clobbers a symlink itself, leaving its target
holding the stale corpus. Resolve the final-path symlink to its real
target before creating the temp file and replacing, so generate/dedup
update the file consumers read through the link. Adds a regression test.
Co-authored-by: Mervin Praison <[email protected]>
Defect #1: a truncated/invalid tool-call argument string no longer executes
the tool with {} and reports success. _parse_tool_call_arguments now returns a
distinct sentinel on JSON parse failure (kept separate from {} = "no args"),
and every tool-execution site emits a retryable tool-error message instead of
dispatching. Covers sync/async, streaming, batched and Responses-API paths.
Defect #2: a temperature configured via Agent(llm={'temperature': ...}) now
actually reaches the request. Public entry points default temperature to None
and _build_completion_params resolves it from self.temperature once; chat/achat
and the internal completion helpers default to None so the configured value is
no longer overridden by a hardcoded 1.0. Unset temperature is omitted so the
provider default applies rather than sending an explicit None.
Co-authored-by: MervinPraison <[email protected]>
- eval/grader.py + judge.py: passing now requires a parsed numeric SCORE
(score_parsed), so a reasoning-only response can no longer fabricate a
passing 5.0 against a low threshold. Multi-line REASONING is captured.
- crawl4ai_tools.py: crawl_many preserves input->output positional order for
mixed safe/blocked batches instead of hoisting blocked entries to the front.
- gateway.py: _force_kill_process polls until the PID is confirmed gone before
returning True, so the PID lock is not released while a process may survive.
- test_grader.py: assert fail-closed (1.0) for an unparseable score.
Co-authored-by: Mervin Praison <[email protected]>
Address reviewer findings on #4236:
- Route direct RuntimeConfig(runtime=...) through resolve_runtime so a
preferred_runtime typo raises instead of silently dropping capabilities.
- resolve_runtime returns a normalised copy of a RuntimeConfig instead of
mutating the caller's object.
- canonical_runtime_name passes the full alias set to the error builder so a
close typo (e.g. "reducd") suggests the matched alias.
- Rename knowledge_qdrant.py -> knowledge_chroma.py (now demonstrates Chroma)
and restore qdrant_wow.py as a genuine Qdrant adapter example that skips
cleanly, matching filenames to content.
- Add tests: no-mutation, typo suggestion, Agent-level normalisation + typo.
Co-authored-by: Mervin Praison <[email protected]>
mkstemp creates the temp file 0600 and os.replace would make that the
final mode, stripping group/other read that a plain open(..., "w") would
grant via umask and locking shared-corpus consumers out. Restore the
destination's existing mode when overwriting, else 0666 & ~umask.
Applies to both generate and dedup; adds two regression tests.
Co-authored-by: Mervin Praison <[email protected]>
- run --continue: restore chat history by position instead of a content-based
filter, so repeated turns (e.g. a bare "yes" confirmation) are no longer
silently dropped; _auto_save_last_index tracks the full restored length.
- session share / export redaction: mask sk_live_/sk_test_ Stripe keys, anchor
the key=value rule on keys that *contain* a secret word (AWS_SECRET_ACCESS_KEY,
STRIPE_SECRET_KEY), and mask secret values whole so a value containing '/' is
not half-masked by the path regex.
- session share now defaults --redact-level to strict, since it publishes a
transcript for wider distribution.
Co-authored-by: MervinPraison <[email protected]>
Address reviewer findings on #4234 (issue #4228):
- agent.py: use `is None` (not falsiness) so an explicit empty permissions
policy (`permissions={}`) owns denial instead of inheriting default denials.
- tool_execution.py: normalize argument keys (strip trailing '=') before
build_permission_target so a malformed `command=` kwarg cannot evade a
scoped deny that only normalizes after the gate.
- tool_execution.py: re-run the deny gate against `decision.modified_args` in
both sync and async paths so an approval backend cannot rewrite allowed args
into a denied command; BYPASS still opts out.
- tests: add regression coverage for all three.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Covers the #4227 failure mode directly (member propagation, not just the
container field) per CodeRabbit review, without a live LLM call.
Co-authored-by: Mervin Praison <[email protected]>
ContextManager built one union kwargs dict and passed it to every optimizer,
but only SmartOptimizer accepted that signature. The other six raised
TypeError, which chat_mixin swallowed with a blanket except, disabling context
management entirely — including the emergency hard-limit truncation.
- get_optimizer now filters kwargs to each optimizer's constructor signature
(single choke point; fixes all six strategies without touching call sites).
- chat_mixin._apply_context_management falls back to emergency truncation on
failure instead of returning the unchanged, over-budget history.
- Add regression tests covering every strategy.
Co-authored-by: MervinPraison <[email protected]>
Four HIGH-severity silent-data-loss bugs in praisonai-train:
- train llm <dataset>: forward the path as --dataset (the option the train
branch reads) instead of a bare positional the legacy dispatcher drops,
which silently trained on yahma/alpaca-cleaned.
- generate: write to a temp file and os.replace only when rows are produced
(mirrors dedup), so an all-failures run no longer truncates an existing
corpus to zero bytes.
- Ollama export: dedent the Modelfile TEMPLATE so continuation lines sit at
column 0 and the served prompt format matches the trained one.
- agent training: a genuine agent failure now re-raises instead of being
captured as "Error: ..." output, graded as training data, and reported as
success with exit 0.
Adds regression tests for all four.
Co-authored-by: MervinPraison <[email protected]>
The scheduler monitor's total-deadline watchdog was a no-op for
Connection: close responses. http.client.getresponse() calls close()
for close-delimited replies, nulling connection.sock, so when the
watchdog fired on exactly the responses that can hang forever there was
no socket left to shut down. A page dripping one byte per socket window
stalled the whole sequential scheduler tick while holding its lease.
Retain a separate raw-socket handle the close() path does not clear and
fall back to it in _abort_connection; clear it after each probe so the
fd is not held past the (per-probe) connection.
Co-authored-by: MervinPraison <[email protected]>
- #4195: grader.py/judge.py now parse markdown-bolded labels and fail closed
(score 1.0) instead of fabricating a mid-range 5.0 that could pass a threshold
- #4192: gateway stop distinguishes PermissionError from a dead process and
keeps a live cross-uid holder's lock
- #4196: telemetry re-entrancy guard uses a ContextVar so concurrent executions
on one agent are counted independently
- #4189: crawl4ai_tools now validates every URL entry point via is_safe_http_url,
and mentions.py re-validates each redirect hop instead of only the first
Co-authored-by: MervinPraison <[email protected]>
(a) The code generator advertised a hardcoded memory capability list that
still named removed backends (redis/postgres/qdrant), so generated agent
code raised before the API. Derive supports_presets/supports_url_schemes
from the live resolver registries (MEMORY_PRESETS, MEMORY_URL_SCHEMES) so
the generator can never teach a dead value. Update the four shipped
examples that crashed at the config boundary to use shipped backends.
(b) runtime= stored any string verbatim, silently dropping nine of twelve
capabilities on a typo. Normalise through canonical_runtime_name() so
spelling variants (NATIVE, " native ", Native) all map to the native
matrix, and raise on a close typo (nativ -> "Did you mean native?").
A genuinely unknown plugin runtime name is still accepted and falls back
to the reduced harness via the runtime registry, not a closed literal.
Co-authored-by: MervinPraison <[email protected]>
(a) The ApprovalConfig and dict approval branches left _perm_deny at its
empty init value, making a config object a WEAKER posture than passing
nothing (ApprovalConfig(timeout=30) silently dropped all 8 default
denials). They now fall back to the env-driven default deny set when no
declarative permissions policy is supplied, so configuring approval never
removes the default denials. PRAISONAI_TOOL_SAFETY=off still fully bypasses.
(b) The PermissionManager deny gate only asked with the tool *name*, so
argument-level deny rules (e.g. bash:rm *) never fired. _check_permission_manager_deny
now also builds the argument-scoped target via build_permission_target and
checks it, so an argument-scoped rule can narrow an allowed shell tool.
Name-level rules keep their all-or-nothing behaviour.
Co-authored-by: MervinPraison <[email protected]>
Three closed defects still reproduced on main, each shielded by a test that
passed on the broken behaviour.
1. Agent.as_tool() returned a success string without running the sub-agent.
ContextPolicy.NONE discards history, so no prompt was ever derived. The
generated tool now advertises a `task` parameter and passes it verbatim as
the sub-agent's prompt.
2. `--type openai-compat` was refused though listed as registered. The endpoint
registry loads under both `praisonai_code.*` and the `praisonai.cli.features`
shim namespace; the relative `from ..._wrapper_bridge` resolved to the
non-existent `praisonai._wrapper_bridge` under the shim. Made it absolute.
3. `from praisonaiagents import db` (and `obs`) yielded a non-callable module,
because a real submodule shadows the package `__getattr__`. The submodules
now swap their `__class__` to a callable ModuleType subclass that proxies to
the lazy singleton, so `db(...)`/`obs(...)` and `db.PraisonDB` both work.
Tests: added a behavioural as_tool test asserting the sub-agent call, a
resolve() assertion under the shim namespace, an obs-callable test, and removed
the bug-detecting workaround from the db test so its assertion can fail without
the fix. Each new test was confirmed to fail against the unfixed tree.
Co-authored-by: MervinPraison <[email protected]>
The containers called resolve_model_alias (alias rule only) while the seven
media/specialist agents call resolve_model_name (alias rule + LLMConfig
unwrapping). AgentTeam/AgentFlow therefore pushed the raw LLMConfig into every
member via _apply_default_llm, and the member's chat() failed silently with
"'LLMConfig' object has no attribute 'lower'", returning None.
Switch both container sites to resolve_model_name and widen the regression
test's parametrize from UNWRAPPING to ALL.
Co-authored-by: MervinPraison <[email protected]>
Evict the 'Task' key from _lazy_cache before spawning threads so the
concurrent access hits the double-checked locking path in
_lazy.lazy_import instead of the unlocked cache fast path. Addresses
Greptile P2: prior test could read an already-cached attribute after
suite collection, letting concurrent first-load regressions go
undetected.
Co-authored-by: Mervin Praison <[email protected]>
Remove dead _lazy_cache_local threading.local and _get_lazy_cache()
helper from praisonaiagents/__init__.py. The runtime lazy-loader only
uses the plain _lazy_cache dict (guarded by the lock in _lazy.py);
the thread-local cache had no runtime consumer.
Repoint TestLazyCacheThreadSafety at the real lazy-loading path
(concurrent access of praisonaiagents.Task resolving to one cached
object) instead of the removed self-referential helper.
Co-authored-by: MervinPraison <[email protected]>
- AuthProfile.credential_id: stable, non-secret (hashed) identity keyed on
provider+api_key+base_url so coordinator benches track the credential, not
the local profile label. Prevents same-name collisions ("default") benching
unrelated keys and different names on the same key breaking propagation.
- mark_success: only clear the shared bench when it is not newer than the
cooldown being recovered from, so a stale in-flight success cannot clobber a
newer fleet-wide bench set by a concurrent failure.
- All three coordinator call sites now use credential_id consistently.
- Tests: add same-name-distinct-keys isolation, stale-success guard, and
own-bench clear coverage; update existing tests to key on credential_id.
Co-authored-by: Mervin Praison <[email protected]>
- state(): catch TypeError so a valid-JSON sentinel with a non-numeric `at`
(e.g. a list) still fails safe engaged instead of raising.
- engage(): commit via tempfile.mkstemp in the sentinel dir + fsync file and
parent dir, closing a predictable-tmp symlink pre-creation vector and making
an engaged brake durable across an abrupt crash; cleanup tmp on failure.
- bot GatewayServerSchema: declare `control` and `turn_lock` so the documented
`gateway.control` YAML block loads under extra="forbid".
- tests: drop hardcoded /tmp path (S108) and add structured-`at` fail-safe case.
Skipped (scope/lightweight mandate): async FileEmergencyStop variants (no live
async admission consumer yet; is_engaged is a fast local stat/read) and
agent.start() agentic tests (this PR ships the core contract + config only, no
admission wiring to exercise via the LLM).
Co-authored-by: Mervin Praison <[email protected]>
Adds a pure, dependency-free QuotaCoordinatorProtocol + QuotaCoordinatorConfig
and an in-memory LocalQuotaCoordinator in core (praisonaiagents/llm/quota.py).
FailoverManager now consults an optional coordinator so a credential benched
by any replica (429/auth/billing cooldown) is skipped by all replicas sharing
the same backend. Defaults to the local in-memory coordinator, so single-replica
/ no-backend deployments behave exactly as before with no new dependency and no
hot-path change. All coordinator calls are fail-open (never wedge failover) and
benches carry TTLs that self-expire.
Co-authored-by: MervinPraison <[email protected]>
Introduce a pure, dependency-free EmergencyStopProtocol in core gateway
plus a zero-cost NullEmergencyStop default and a durable, fail-safe
FileEmergencyStop sentinel. Wire an EmergencyStopConfig (gateway.control)
into GatewayConfig defaulting to backend "off" so behaviour is unchanged.
This is the shared contract every new-work admission seam can consult to
halt new runs while preserving in-flight ones; a corrupt/unreadable
sentinel fails safe (engaged).
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: MervinPraison <[email protected]>
- Only genuine booleans count as a restart_safe declaration; malformed values
(e.g. the string "False") are treated as unsafe instead of coerced truthy.
- Empty tool names fail closed rather than falling through to the read-only
heuristic which returns True for "".
- Declaration lookup now compares canonicalised names so an explicit
restart_safe declaration survives execute_tool's case/separator repair.
Co-authored-by: Mervin Praison <[email protected]>
CodeRabbit: session.persist:true but a failed persistent-store init degrades
to in-memory (self._session_store is None). The old config-based default still
enabled ExecutionConfig(durable=True), journalling against no durable store.
Resolve the auto-default from the instantiated self._session_store instead;
explicit gateway.durable_runs still wins. Adds a regression test.
Co-authored-by: Mervin Praison <[email protected]>
Add a `restart_safe` declaration to the tool contract (@tool / BaseTool /
FunctionTool) and honour it during durable resume. An in-flight tool call
(journalled tool_call with no tool_result at the crash) is now re-run only
when the tool resolves as restart-safe (explicit declaration, else the
read-only name heuristic); effectful/undeclared tools fail closed with a
typed NotSafelyResumable outcome instead of silently re-executing, removing
the duplicated-side-effect risk on crash recovery.
Co-authored-by: MervinPraison <[email protected]>
Auto-enable durable (journal-backed) gateway runs when a durable session
store is present (the default), so a restart mid-turn replays completed
model/tool steps instead of re-executing side-effecting tools or re-billing
the model. Zero-overhead escape hatches: explicit `gateway.durable_runs:
false` or `gateway.reliability: "off"`, and sessions with `persist: false`.
An explicit `durable_runs` and per-agent `durable` still win.
Co-authored-by: MervinPraison <[email protected]>
- firestore.hdel: count only fields actually present instead of len(fields)
- dynamodb.hset/hdel: migrate legacy JSON-string hashes into native map so
existing fields stay visible and legacy deletions are effective; count actual
- recipe/serve.py: drain SSE queue + stop event on consumer cancellation so the
producer worker thread unblocks instead of leaking the executor
Co-authored-by: Mervin Praison <[email protected]>
- integrations/base.py: reap the child in a finally block in execute_async
and execute_async_with_stderr so caller cancellation (CancelledError) no
longer leaks a running subprocess plus its stdout/stderr pipes.
- recipe/serve.py: route sync recipe.* calls through asyncio.to_thread and
bridge the sync run_stream generator to an asyncio.Queue on a worker
thread so a single recipe run no longer blocks the whole event loop.
- persistence/state/firestore.py: scope credentials to the client instead
of mutating process-wide os.environ, and use atomic field-level merge /
DELETE_FIELD for hset/hdel to avoid lost concurrent updates.
- persistence/state/dynamodb.py: use atomic UpdateExpression on a native
map attribute for hset/hdel to avoid non-atomic read-modify-write races.
Co-authored-by: MervinPraison <[email protected]>
- handoff.py: make seeded handoff context invocation-scoped via a
restore-on-exit context manager so target chat_history no longer leaks
across sequential/concurrent handoffs or grows unbounded (CodeRabbit/Greptile P1)
- handoff.py: exclude the exact message folded into the prompt (by index)
from seeded history instead of always dropping the final list item
- memory.py: get_context returns "" for empty/None query (no LIKE "%%"
unrestricted match) and propagates the effective user_id for user-scoped memories
- tool_execution.py: resolve explicit idempotent flag on process-global
registry tools before falling back to the shared name registries
Co-authored-by: Mervin Praison <[email protected]>
Bug #1 — retrieved memory never reached the prompt:
- build_context_for_task defaulted include_in_output to a DEBUG-only gate, so
Agent(memory=True) retrieved hits but returned "" under normal logging. Default
to True (the method's primary purpose is feeding memory into the prompt).
- Added Memory.get_context() so the non-cache path (get_memory_context) works;
previously only FileMemory had it, so hasattr(...) was always False.
Bug #2 — timed-out tool auto-retried while still running:
- future.cancel() is a no-op once a worker starts, so the tool body keeps running.
Added _is_tool_idempotent() and gated the _outer_timeout retry on it, reusing
the escalation loop_guard read-only/mutating registry plus an optional per-tool
`idempotent` flag. Mutating tools now surface the timeout once instead of
duplicating side effects.
Bug #3 — HandoffConfig context policy computed then discarded:
- _prepare_context() result was thrown away; the target only got the prompt string.
Added _seed_target_history() and threaded the filtered messages through
_execute_with_runtime_resolution(_async) for sync/async Handoff and TypedHandoff.
Co-authored-by: Mervin Praison <[email protected]>
- llm/llm.py: add response.reasoning_summary_text.delta branch to
_stream_responses_api_async so async streaming emits reasoning-summary
deltas, matching the sync path.
- _warning_patch.py: replace over-broad bare "Expected" substring with
precise "fields but got" so unrelated third-party/user warnings are no
longer swallowed process-wide.
Co-authored-by: MervinPraison <[email protected]>
Address the two P1 findings on the inbound webhook dedup durability change
(#4208):
- SQLite backend was unreachable: _get_hook_idem_store read a GatewayConfig
field that no config path populates. Capture hooks.idempotency.store_backend
in _apply_hooks_from_config (the only path that sees the raw hooks dict),
supporting both the mapping-shaped hooks.idempotency and a sibling
hooks_idempotency key, and rebuild the store on a hot-reload backend flip.
- In-flight claims survived a crash: a process exit between reserve and
record/release left a durable inflight row that deduplicated the provider's
post-restart retry for the full TTL. Add a bounded inflight lease; a stale
inflight reservation is reclaimed on reserve so the retry re-runs, while
recorded keys keep deduplicating until TTL.
Adds regression tests for config wiring (default/sqlite/sibling/hot-reload) and
crash reclaim (stale inflight reclaimed; recorded not reclaimed).
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Add a core IdempotencyStoreProtocol (reserve/record/release) with a bounded
in-memory default, and a durable SQLite backend in praisonai-bot. The gateway's
inbound hook handler now delegates dedup to a swappable store selected via
hooks.idempotency.store_backend ("memory" default; "sqlite" for restart/
multi-replica durability), so a provider redelivery after a restart no longer
starts a duplicate agent run. Mirrors the existing ingress-journal/outbound-
queue durability.
Co-authored-by: MervinPraison <[email protected]>
Register MODEL_FALLBACK_STREAM, RETRY_STREAM, TODO_STREAM, and
TOOL_RESULT_STREAM in the streaming capability handshake and the
session-history allowlist so clients can discover these events and
so they receive a cursor for reconnect/resume replay (fixes gap
flagged by CodeRabbit/Greptile reviews on #4206).
Co-authored-by: Mervin Praison <[email protected]>
The WebSocket gateway relay dropped four StreamEventTypes the SDK already
emits (MODEL_FALLBACK, RETRY, TODO_UPDATED, TOOL_CALL_RESULT), causing live
clients to see an unexplained token-stream stall during fallback/backoff.
- Add additive EventType members (MODEL_FALLBACK_STREAM, RETRY_STREAM,
TODO_STREAM, TOOL_RESULT_STREAM) to gateway protocol enum
- Map the four events through the existing cross-thread relay send path,
carrying their existing metadata untouched
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
The smoke job installs praisonai-mcp==0.0.15 as editable, but the wrapper's
`praisonai-mcp>=0.1.0` constraint then made pip fetch the stale PyPI wheel
praisonai_mcp-0.1.1 (no `cli` submodule), clobbering the editable install and
breaking `praisonai_mcp.cli` (test_mcp_list). Bumping the local package to
0.1.2 satisfies the constraint so the editable install is retained.
Co-authored-by: Mervin Praison <[email protected]>
- schedules_runner: pass timeout=None to run_sync_or_offload so long
scheduled agent/command runs are not cancelled by the bridge's default
300s timeout after ScheduleLoop has already claimed the occurrence
(would silently drop one-shot / long-interval work). Restores the prior
unbounded asyncio.run(...) behaviour. (Greptile P1)
- security/audit: close the file handle best-effort in the rotation
OSError branch before dropping the reference, so an fstat/close failure
cannot leak the descriptor. (Greptile P2)
Co-authored-by: Mervin Praison <[email protected]>
- scheduler on_trigger now uses run_sync_or_offload instead of asyncio.run,
so scheduled ticks share the async bridge / per-loop connection pools
- AuditLogHook reopens its handle when the log path is rotated (mv/rm),
preventing silent loss of audit records after logrotate
- LazyCache memoises every terminal loader failure (not just ImportError),
while letting KeyboardInterrupt/SystemExit propagate, avoiding hot-path
cache stampedes on broken optional deps
Co-authored-by: MervinPraison <[email protected]>
Addresses reviewer P1 findings on the Memory batch-commit path:
- Deadlock: commit_memory_batch no longer holds the non-reentrant
_write_lock while calling store_short_term/store_long_term (which
reacquire it). Writes now dispatch outside the lock.
- Atomicity/cancellation: both sync and async batch commits route each
write through _AtomicCommitGuard.commit(deadline=...) so cancelled or
past-deadline facts are skipped; acommit offloads blocking stores via
asyncio.to_thread instead of blocking the event loop.
- Path traversal: default sqlite/chroma store paths derive a
filesystem-safe suffix from user_id, rejecting separators/parent refs
and sanitizing other characters (bare stores keep legacy defaults).
Co-authored-by: Mervin Praison <[email protected]>
- Thread the per-agent mem_user_id into every Memory(...) construction path in
agent.py (string and dict forms) via config.setdefault, and scope the default
sqlite/chroma paths and collection_name by user_id in Memory so two agents in
one process no longer silently share one store. Bare stores keep legacy
defaults for backward compatibility.
- Add commit_memory_batch/acommit_memory_batch to the Memory class so the
pre-compaction fact flush durably commits extracted facts for
sqlite/chroma/mongodb/mem0 instead of raising and being swallowed.
- Propagate a nested on_error="stop" stop signal out of Parallel: _execute_parallel
now returns a stop flag, the nested dispatcher forwards it, and the top-level
loop halts like Route/Loop/Repeat/If. Keep parallel_outputs index-aligned with
the branch list on partial_ok failures.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: MervinPraison <[email protected]>
The URL monitor's response.read() bounded bytes and per-socket waits but
had no total deadline, so a slow drip-feed endpoint could keep the
synchronous read alive within each socket window and stall the sequential
scheduler tick, delaying other due jobs. Read the body in bounded chunks
and check an absolute deadline between reads so a slow response fails the
probe at the monitor timeout instead of blocking progress.
Co-authored-by: Mervin Praison <[email protected]>
The existing collision guard only ensured no two keys collapse to the same
canonical form. It did not catch a single non-canonical key (e.g. 'full-auto'),
which validation would accept but the direct dict.get(canonical_preset_key(...))
lookup would miss, silently falling back to the disabled default. Add an
invariant test enforcing key == canonical_preset_key(key) for all four
resolver preset dicts. (addresses Greptile review)
Co-authored-by: Mervin Praison <[email protected]>
Validation normalised strip().lower().replace('-','_') while the four
resolvers did a raw dict .get(), so blessed spellings like
autonomy='full-auto' or context='sliding-window' were accepted then
silently ran with the feature off.
Add canonical_preset_key() in parse_utils and route both the validator
and the context/autonomy/output/execution resolvers through it. Replace
the vacuous test_normalized_preset_accepted assertion with the
resolution checks its own comment described.
Co-authored-by: MervinPraison <[email protected]>
_maybe_deliver_failure only recorded the negative outcome, leaving
JobResult.delivered False even when the failure summary was delivered
successfully — so monitoring reported a delivered notification as
undelivered (Greptile P1 on PR #4198). Set result.delivered = True on
the success branch and add regression tests for both the delivered and
failed failure-summary paths.
Co-authored-by: Mervin Praison <[email protected]>
_deliver_scheduled_result returned None on every path, so the executor
treated 'the handler did not raise' as success — a routing failure was
recorded delivered=True with delivery_error=None next to a logged
'Failed to deliver'. Return a bool from the gateway handler, propagate it
through _dispatch_delivery, and key delivered/delivery_error off it at the
three executor call sites plus the failure-summary path.
Co-authored-by: MervinPraison <[email protected]>
Snapshot and restore the pre-existing module entry in the vendored-fallback
import helper so the forced standalone re-import cannot leak the fallback
PluginRegistry into later tests expecting the bridged canonical class.
Addresses CodeRabbit review on #4184.
Co-authored-by: Mervin Praison <[email protected]>
vendored fallback copies used when praisonai-sandbox / praisonai-deploy are
installed standalone did not get it. Apply the same guard so an installed
distribution can never replace a built-in loader via entry-point discovery.
Add a class-parametrised test that forces the standalone fallback import path,
proving each vendored copy is protected (the previous test only exercised the
canonical class through varied group-name strings).
Co-authored-by: MervinPraison <[email protected]>
EndpointProviderRegistry.default() caches a per-class singleton whose
entry-point discovery runs once at construction. cmd_invoke resolves
providers through that cache, so under the CI root shard (-n 2 --dist
loadfile) a singleton built by an earlier test on the same worker — before
this file publishes the `acme` plugin — never sees it, and invoke falls back
to the recipe HTTP path (assert http == [] => assert 2 == 0). Evict the
cached singletons in the `published` fixture before and after each case so
the plugin is rediscovered and no poisoned instance leaks forward.
Test-only change in the praisonai wrapper; no production or praisonai-agents
code touched. Strengthens isolation rather than weakening any assertion.
Co-authored-by: Mervin Praison <[email protected]>
Wrapping only chat() dropped all execution telemetry for start(stream=True)
(-> _start_stream) and backend-backed run()/start() (-> _delegate_to_backend),
neither of which funnels through chat(). Re-instrument run/start/chat behind a
per-agent re-entrancy flag so the outermost call records exactly one execution,
preserving single-count for run()->chat() while covering stream/backend paths.
Co-authored-by: Mervin Praison <[email protected]>
The full-suite CI failure (`assert 2 == 0`, "Unknown provider type: acme")
was a test-isolation bug: the CLI handler's cmd_invoke resolves
EndpointProviderRegistry via the `praisonai.cli.features._endpoint_registry`
shim namespace, a distinct class object from the `praisonai_code...` one the
test imports. Their default() singletons are cached independently, so an
earlier full-suite test that warmed the handler's cache left it stale and the
monkeypatched entry points never reached it. Reset the cached _default_instance
on both class objects so discovery re-runs under the patched entry points.
Unrelated to PR #4187's session-docs changes; fixes the pre-existing flake.
Co-authored-by: Mervin Praison <[email protected]>
- token_collector: total_tokens no longer double-counts cached/reasoning/audio
tokens (they are subsets of input/output, not additional buckets)
- cost_utils: replace loose substring matching with longest-key exact/prefix
match so gpt-4o-mini is no longer billed at gpt-4o rates (16.7x); strip
provider prefixes; exclude "default" from matching
- performance_monitor: consult shared telemetry state live so a later
disable_telemetry() actually stops the import-time global monitor
- telemetry.__init__: atexit handler no longer resurrects a shut-down/opted-out
telemetry instance at interpreter exit
- integration: instrument only chat()/execute_tool(); run()/start() delegate to
chat() so wrapping them double-counted executions and errors
- telemetry.flush: only zero counters after a successful send
Co-authored-by: MervinPraison <[email protected]>
- _execute_loop/route/repeat/if now return a `stop` flag so a nested
on_error="stop" step halts the enclosing workflow instead of only its
own pattern iteration. Both nested dispatch and top-level _run_impl
honor the propagated signal.
- Parallel fail_fast/fail_all no longer `raise ... from <str>` (which
raised TypeError and masked the real failure); the string branch error
is preserved in WorkflowStepError.errors and only real exceptions chain.
- Added regression tests: nested-stop halts outer workflow, and parallel
fail_fast reports failed without TypeError.
Co-authored-by: Mervin Praison <[email protected]>
Parse safety judge responses tolerantly (markdown bolding, bullets, case)
and fail closed when nothing usable is parsed. Previously a bolded field
label - the default output style of most models - matched nothing and the
verdict silently fell through to 10.0/10 SAFE, even on a detected severe
violation. An unparsed verdict is now treated as UNSAFE (1.0), matching the
existing exception handler in evaluate_safety.
Co-authored-by: MervinPraison <[email protected]>
- _is_process_running: treat PermissionError as running so a gateway owned
by another uid is not reported dead and its lock deleted, admitting a
second poller for the same bot token.
- Record a create_time fingerprint in the lock so a recycled PID cannot be
mistaken for the original gateway; verified before stop/refuse decisions.
- check_port_available: use net_connections with a connections() fallback and
a broad except so the port-collision diagnostic works on psutil 7 instead
of raising 'invalid attr name connections'.
Co-authored-by: MervinPraison <[email protected]>
Previously is_safe_http_url returned True for any allowlisted hostname before
resolving/classifying its addresses, so SEARXNG_URL_ALLOWLIST=169.254.169.254
could reach the cloud metadata endpoint. Now every address is classified and an
allowlisted host only bypasses the loopback class; private, link-local,
multicast and unspecified addresses stay blocked. Docstring corrected to match.
Co-authored-by: Mervin Praison <[email protected]>
Nested patterns (loop/parallel/when/route/repeat/if) routed failing steps
through _apply_step_policies, which folded f"Error: {e}" into the step output
and never consulted on_error. Wrapping a pipeline in any pattern therefore lost
failure detection: the run reported status="completed" and the next step
consumed the exception text as input.
_apply_step_policies now returns (output, step_error) instead of folding the
error into output. _execute_single_step_internal applies the same
on_error/status logic the top-level _run_impl already uses: it marks the run
failed, signals a stop when on_error="stop", and never hands the exception text
forward. Pattern executors honor the stop signal, and Parallel on_failure modes
fire again since the branch failure is now surfaced.
Co-authored-by: MervinPraison <[email protected]>
The shared is_safe_http_url unconditionally allowlisted loopback and
consulted it before the IP-class checks, re-opening web_crawl to
127.0.0.1/localhost/::1. Split the allowlist into an explicit per-caller
opt-in consulted after the IP checks. validate_searxng_url opts into
loopback (SearXNG is self-hosted); the crawler passes none.
Co-authored-by: MervinPraison <[email protected]>
The restored TOP_LEVEL_KWARG regex only balanced one level of parens, so
a top-level rejected kwarg after a two-level nested call
(Agent(memory=MemoryConfig(db=db(...)), session_id=...)) escaped the guard,
re-opening the #4178 regression (greptile). Replace the regex with a
depth-aware scanner that attributes each kwarg to its enclosing call at any
nesting depth, and skips string literals and comments so dict keys
(memory={"session_id": ...}) and comments (# session_id = resume) are not
false positives. Add regression tests for the nested-call, dict-key, comment
and prose cases.
Co-authored-by: Mervin Praison <[email protected]>
Addresses Greptile's P2 finding on this PR plus one gap found reviewing the
diff independently.
- pattern-d-platform.md: the note added in the previous commit corrected the
platform agent roster path, but line 25 still listed it as /api/v1/agents,
which the same note defines as the unrelated endpoint served by
`praisonai serve agents`. A reader following the numbered list would have
integrated against the wrong service. Also softened "Three registries may
coexist" — a full install has more than three; the three listed are the ones
relevant to agent_id correlation.
- auth/__init__.py: the module docstring said implementations "live in the
platform package" without saying where that is, which is now ambiguous since
the package left this repository. Points at the separate repo and, more
usefully, states first that you can implement the protocols against your own
store. This matches the change made to protocols.py in the previous commit.
src/praisonai-agents/tests/unit/auth/ passes (33 tests).
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZfTXTzdudKb4oswNSHpHw
praisonai-platform is extracted to MervinPraison/PraisonAI-Platform, with full
history carried over via git filter-repo.
Why it belongs outside this repo:
- Nothing here imports it. A grep for praisonai_platform across every .py
outside its own directory returns no hits, and no pyproject declares it.
- It is in no CI job. No workflow under .github/ references it — it is absent
from install-monorepo-packages and from pypi-release.yml, which enumerates the
nine tiered packages and notes it is near GitHub's 25-input cap.
- It is absent from ARCHITECTURE.md, which describes a nine-package tiered
model; the platform was a tenth, untiered package with no boundary manifest.
- Separate-repo is the ecosystem norm: PraisonAI-Tools, PraisonAI-Plugins,
PraisonAIUI and Agent-Recipes all live outside and consume praisonaiagents as
a plain versioned PyPI dependency. The platform already did exactly that, and
already carried its own uv.lock.
- Its releases were already documented as an independent semver line.
Changes here:
- Remove src/praisonai-platform/ (9,263 lines).
- SECURITY.md: drop the platform row from supported packages and point
vulnerability reports at the new repository's advisories.
- security-audit.md: the platform is no longer triaged, released or audited
from this runbook. Removes publish steps 13 and 15 and the two table rows so
an agent executing it cannot cd into a directory that no longer exists.
- auth/protocols.py: WorkspaceContextProtocol's example named
PlatformWorkspaceContext, a class no longer in this tree. Renamed to a
generic example and noted where the reference implementation lives. Core
keeps the protocol with no in-tree implementation, which is the documented
design — auth/__init__.py already states that implementations live in the
platform package, and the core tests use local mocks.
- pattern-d-platform.md: kept, since it documents an integration contract
rather than platform internals. Adds a pointer to the new repo and corrects
the agent roster path, which is /api/v1/workspaces/{id}/agents — /api/v1/agents
is a different endpoint served by `praisonai serve agents`.
src/praisonai-agents/tests/unit/auth/ passes (33 tests).
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZfTXTzdudKb4oswNSHpHw
PR #4179 replaced the textual kwarg guard with an executing one but the
executor is blind to session/__init__.py (documented via an indented
docstring, zero python fences) and to README blocks excluded by
_is_self_contained. The surviving text check only covered _EXAMPLE_FILES,
so the original #4178 bug (a top-level Agent(session_id=...)) could be
reintroduced with the suite green.
- Restore test_docs_do_not_pass_rejected_kwargs_to_agent, widened to cover
DOCS as well as _EXAMPLE_FILES. TOP_LEVEL_KWARG matches only inside
Agent(, so prose mentioning session_id= is not a false positive.
- Add test proving an executor-only guard is blind to a fenceless file.
- Fix session README "Using with DB Adapter" block: import the callable
via `from praisonaiagents.db import db` instead of `from praisonaiagents
import db`, which yields the non-callable module and raised
TypeError: 'module' object is not callable whenever the wrapper was
importable (README.md#3 was green only via importorskip).
Co-authored-by: MervinPraison <[email protected]>
The deprecated entry-point group bypassed #4171's built-in guard because
_discover_legacy_entry_points ran first and called _add_loader
unconditionally. A plugin published under the legacy spelling could thus
replace a built-in, contradicting the docstring. Apply the same built-in
guard to the legacy path.
Co-authored-by: MervinPraison <[email protected]>
One helper resolves the llm=/model= pair everywhere: model= is canonical,
llm= is a deprecated alias, passing both raises TypeError, and LLMConfig is
unwrapped for classes that store a bare model name. Adds model= to CodeAgent,
RealtimeAgent, AgentTeam and AgentFlow.
Co-authored-by: MervinPraison <[email protected]>
The main (3.11) job dropped the plugin `aider` across every registry-backed
surface (fixture tests + entry-point discovery test). The base guard and
production code are correct; the failure is a test-isolation issue that only
surfaces under `pytest -n --dist loadfile` + coverage on CI:
- The entry-point discovery test patched `praisonai_code._registry.entry_points`
by dotted string. Under editable-install + coverage import tracing a
re-imported copy of that module can differ from the one the
`ExternalAgentRegistry` class actually discovers through, leaving the real
path unpatched and silently dropping `aider`. Patch `entry_points` on the
exact base module the class resolves via `__mro__`, and accept `group`
positionally or by keyword.
- The `registered_plugin` fixture now also clears and restores the per-class
`PluginRegistry.default()` cache so no sibling test file sharing the xdist
worker can shadow the registered plugin.
No assertions weakened: built-ins still win over colliding entry points,
plugins can still add new names, and every surface must still agree.
Co-authored-by: Mervin Praison <[email protected]>
Addresses two blind spots greptile flagged in the executing session-doc guard:
- ImportError from our own packages (praisonaiagents/praisonai) now re-raises
instead of being swallowed by the optional-dependency skip, so a doc that
advertises a renamed/removed symbol fails the guard. Optional third-party
deps still skip.
- Blocks dropped by the self-contained filter are now asserted against a known
set of documentation idioms, so a typo/omitted import in an otherwise-complete
example fails a diagnostic test rather than vanishing from execution.
Co-authored-by: Mervin Praison <[email protected]>
The #4127 guard regexed the docs for four hard-coded kwarg names, so
renaming the fixed example's session_id= to sessionid= left it green while
the documented MemoryConfig call raised. Replace the text match with a
primary guard that extracts every ```python fence, keeps the AST
self-contained ones (used - bound - builtins == set()), and execs them with
Agent.start/chat stubbed and PRAISONAI_HOME isolated. It now fails on the
sessionid= reproduction with the exact TypeError, and catches the next
mistake too. Non-hermetic examples opt out via <!-- praisonai: skip=true -->
and wrapper-backed db examples are skipped when praisonai is absent.
Co-authored-by: MervinPraison <[email protected]>
The entry-point shadowing guard is added inside praisonai-code 0.0.64, but the
wrapper floor was praisonai-code>=0.0.64 — the same version number that shipped
without the guard. A pre-fix 0.0.64 install would satisfy >=0.0.64 yet lack the
protection, restoring the shadowing path once the wrapper's local guard was
removed. Bump praisonai-code to 0.0.65 and raise the wrapper floor so the
dependency constraint actually guarantees the fix is present.
Co-authored-by: Mervin Praison <[email protected]>
Add deterministic tests exercising the public execute_tool_async path
(not just the impl) to close the coverage gap CodeRabbit flagged:
- a tool-owned `retryable` field does not drive the async retry loop
(body runs exactly once, field preserved)
- the private `_praison_retryable` control key is not visible on the
async surfaced result
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Extract the tool function name before json.loads so callers (streaming
verbose warning, deferred-media path) report the actual tool name instead
of 'unknown function' on malformed arguments.
Co-authored-by: Mervin Praison <[email protected]>
Relocate praisonaiagents/runtime/example_harness.py (365-line, unreferenced
demonstration module) to examples/runtime/example_harness.py so it no longer
ships in the installed praisonaiagents wheel. Convert its relative imports to
absolute and correct the pre-existing broken TurnRuntimeProtocol import
(runtime.protocols, not runtime.turn_context) so the example runs standalone.
Update the AGENTS.md path reference. No public API or runtime module changes.
Co-authored-by: MervinPraison <[email protected]>
The primary --external-agent execution path (get_integration/execute) called
_catalog() which discarded the too-old-wrapper diagnostic and returned an empty
catalog, producing a misleading "Unknown integration" error on a version-skewed
install (Greptile P1). Add _require_catalog() that raises the upgrade guidance on
the execution path while listing surfaces (help/argparse/UI) keep degrading
quietly. execute() now catches RuntimeError to print the guidance cleanly.
Also fix test hygiene: restore the process-global root logger handlers via
monkeypatch instead of permanently clearing them (CodeRabbit).
Co-authored-by: Mervin Praison <[email protected]>
#4158 patched only ExternalAgentRegistry. The real hole was in the base
PluginRegistry: builtins are registered first and entry points second, so any
installed distribution declaring a built-in name silently replaced it across
all ten registries (openai, docker, aws, ...). Those loaders decide where user
code runs.
Record built-in names once and skip colliding entry points during discovery,
matching the existing BotPlatformRegistry precedent (DEBUG, not WARNING, since
packages legitimately re-declare their own builtins). Runtime register() still
overrides -- deliberate dependency injection. The now-redundant re-assertion in
ExternalAgentRegistry is removed.
Co-authored-by: MervinPraison <[email protected]>
Extract a private _extract_tool_call_name_and_args helper and reuse it in
the non-streaming, streaming, and deferred-media tool-calling paths. Each
call site keeps its own failure handling (tool-role reply vs. yield), so
behaviour is unchanged.
Co-authored-by: MervinPraison <[email protected]>
The retry machinery tagged its own verdict with an un-namespaced `retryable`
key, so a tool whose result legitimately contained `retryable` (common for
HTTP-API wrappers) had its body re-executed up to 3x and the internal verdict
leaked to the model. The sync outer loop separately escalated any returned
error dict of an uncovered type to retryable, re-running the tool body for a
decision it had already made.
- Rename the framework verdict to the private `_praison_retryable` key in the
async impl (exception + timeout branches) and the sync `_execute_tool_impl`
exception flatten, matching the `_outer_timeout` convention from #4133.
- Read and strip `_praison_retryable` in both retry loops so it never reaches
the model and cannot collide with a tool's own `retryable` field.
- Sync outer loop escalates only framework-produced transients
(`_outer_timeout`, `circuit_open`, or a flattened raised exception via
`_praison_retryable`, and only when the type is not already re-driven by the
inner loop). A plain returned error dict now runs exactly once.
- Update existing parity tests to the new key and add regression tests proving
a tool's own `retryable` field does not drive the loop and the verdict is not
visible to the model.
Co-authored-by: MervinPraison <[email protected]>
- list_external_agents() now derives from external_agent_catalog() so argparse
--help choices can never advertise a plugin the handler then rejects
- registry uses a module logger instead of the root logger (no basicConfig on
the embedding application)
- praisonai-code distinguishes an absent wrapper from a too-old wrapper: a
mixed-version install now reports "too old" instead of a silent empty catalog
Co-authored-by: MervinPraison <[email protected]>
The prefix filter in _mistyped_command_suggestions spared both extensions
(``tests`` for ``test``) and truncations (``deplo`` for ``deploy``) of a
command. Truncations are typos: sparing them forwarded the word to ``run``
and billed a mistaken LLM call per invocation. Drop the truncation half so
only extensions — plausible prompts — stay untouched.
Co-authored-by: MervinPraison <[email protected]>
After the bounded verify loop exhausts, nothing was persisted, so returning
True made the caller's 'history was lost' branch unreachable and printed a
misleading 'raced with a concurrent writer' warning. Return False and log the
real failure so the caller's existing warning fires.
Co-authored-by: MervinPraison <[email protected]>
If _EXAMPLE_ROOTS is renamed/moved the guard silently emitted no
parametrized cases, letting the Agent(session_id=)/db= regression it
protects against slip back in. Raise a clear collection error instead.
Co-authored-by: Mervin Praison <[email protected]>
Passing limit=None to migrate legacy transcripts (fixing #4160) turned an
already per-agent full-scan into a full-disk scan on every save: the save
loop over N agents re-enumerated every stored session N times, even once
migrated. Cache the scan result per Session keyed on the opt-in flag, since
legacy records are a frozen migration artefact never mutated by save or
restore. Addresses the P2 hot-path regression raised in PR review.
Co-authored-by: Mervin Praison <[email protected]>
Replace the fictional PostgresAdapter import in session/README.md with the
real db(database_url=...) shortcut under MemoryConfig, and move the top-level
db=/session_id= Agent kwargs into MemoryConfig across the db package
docstrings and the runnable redis example. Also align the session_dir default
path in the API reference (~/.praisonai/sessions).
Extend the doc-example guard to discover all Agent( examples under the db
package and examples/persistence, and assert none reference a class we do not
ship, closing the gap the previous two-file textual guard left open.
Co-authored-by: MervinPraison <[email protected]>
_legacy_agent_histories called list_sessions() with no argument, inheriting
its default limit=50 (sorted by updated_at desc). A legacy per-agent record is
by definition older than anything written after the upgrade, so any user who
touched 50 sessions since upgrading had their tagged sub-agent transcript
silently skipped on restore. Pass limit=None (with a TypeError fallback for
stores whose limit is not optional) so migration scans every stored session.
Co-authored-by: MervinPraison <[email protected]>
Re-assert built-in loaders after entry-point discovery so a third-party
distribution publishing a built-in name (e.g. "claude") can no longer
silently replace the shipped ClaudeCodeIntegration. Plugins may still add
new external agents. Adds a regression test.
Co-authored-by: MervinPraison <[email protected]>
- praisonai-code endpoint registry loaded openai-compat via a static
`from praisonai.endpoints...` import, tripping the C7 hot-path import gate
(smoke CI). Route it through the sanctioned _wrapper_bridge dynamic import so
the standalone package no longer statically imports the wrapper.
- Provider adapter routed --stream through invoke(stream=True), whose terminal
InvokeResult the CLI cannot render (Greptile P1). Consume
BaseProvider.invoke_stream() instead and print SSE-shaped events.
- Add a streaming test asserting --stream drives invoke_stream, not invoke.
Co-authored-by: Mervin Praison <[email protected]>
- praisonai-code external_agents handler now resolves the registry catalog
through _wrapper_bridge.optional_wrapper_attr instead of a direct
``from praisonai.integrations.registry import ...`` reverse import. This
restores the C7/C8 import boundary (the smoke `check_c7_imports.sh` gate)
and degrades to an empty catalog when the wrapper is absent so the handler
stays usable on a standalone praisonai-code install.
- external_agent_catalog() now skips any external agent whose entry point
raises a non-ValueError during load (Greptile P1), so one broken optional
plugin can no longer blank the catalog for argparse/CLI/UI surfaces.
Co-authored-by: MervinPraison <[email protected]>
Resolves two Greptile P1 issues on PR #4155:
- validate command accessed provider.value which crashes when the
provider is a registry plugin string; now handles both enum and str.
- sample YAML generation fell through to a hardcoded aws block for
plugin providers; now emits the requested provider name.
Co-authored-by: Mervin Praison <[email protected]>
_apply_default_llm reset _llm_instance but left the chat-mixin
_unified_dispatcher cached against the old model, so a used agent (team
default applied post-execution, or an AgentFlow re-run with a new llm)
kept calling the previous model. Drop the dispatcher too; add regression
tests for used and pinned agents.
Co-authored-by: MervinPraison <[email protected]>
Address the two P1 review findings on the retry consolidation:
- N² attempts: the outer loop re-escalated timeout/circuit_open results as
retryable even though the inner circuit-breaker loop already retries those
error types to exhaustion, restarting the whole inner budget on every outer
pass (up to max_attempts^2 tool-body runs on a persistently-timing-out MCP
tool). Route every error dict through one guard: only escalate error types
the inner loop does NOT retry. A genuine outer wall-clock timeout is tagged
(_outer_timeout) so it still earns its single outer retry pass.
- Backoff parity: translating ExecutionConfig into a RetryPolicy silently
dropped the delay cap from BackoffPolicy's 60s to RetryPolicy's 30s. Lift the
translated max_delay_ms back to 60s so existing ExecutionConfig users keep the
same delay ceiling.
Adds 5 tests: inner-retried timeout capped at max_attempts (not N^2),
non-inner-retried errors still get outer passes, and the 60s cap is preserved.
Co-authored-by: Mervin Praison <[email protected]>
Both endpoint-provider readers now scan the canonical
`praisonai.endpoint_providers` group, with `praisonai.endpoints.providers`
honoured as a deprecated alias (DeprecationWarning). Registers the orphaned
`openai-compat` provider, adapts BaseProvider subclasses into the CLI dispatch
shape, re-asserts CLI builtins so a plugin cannot hijack a shipped type, and
replaces the silent `_invoke_recipe` fallback for unknown `--type` with a
validation error. Declares the group in pyproject.toml and documents it.
Co-authored-by: MervinPraison <[email protected]>
Collapse the four hand-written copies of the external-agent list into a
single source of truth in the registry. Adds list_external_agents() and
external_agent_catalog() to praisonai.integrations.registry; the argparse
--external-agent choices, the praisonai_code CLI handler INTEGRATIONS, and
the UI EXTERNAL_AGENTS toggles now all read the registry. Presentation
metadata (display_label, install_hint) moves onto the integration classes.
Backward-compatible: INTEGRATIONS and EXTERNAL_AGENTS stay Mappings
supporting in / iteration / []. Built-in order and labels are unchanged on
a default install.
Co-authored-by: MervinPraison <[email protected]>
The CloudProvider enum could not be extended at runtime, so plugin
providers discovered/resolved via the praisonai.deploy.providers
entry-point group were rejected by validation. Widen CloudConfig.provider
to Union[CloudProvider, str] with a mode="before" validator that consults
the registry via coerce_cloud_provider. Built-ins keep enum identity and
byte-identical serialisation; plugins validate as normalised lowercase names.
Co-authored-by: MervinPraison <[email protected]>
The broad async exception handler flattened every raised exception into an
error dict with retryable = not isinstance(e, (ValueError, TypeError,
AttributeError)). A ToolExecutionError(is_retryable=False) is none of those,
so it was silently marked retryable=True and re-run up to max_attempts,
diverging from the sync path (tool_execution.py) which preserves an existing
ToolExecutionError's explicit verdict. Now the async path honours
e.is_retryable for ToolExecutionError and only applies the programming-error
rule to other exceptions.
Adds tests for terminal and retryable ToolExecutionError parity.
Co-authored-by: Mervin Praison <[email protected]>
IntegrationRegistry.__init__ reassigned self._loaders = {} right after
super().__init__() populated it from the entry-point group, discarding
every discovered third-party plugin. Remove the reassignment (builtins are
registered afterwards and still win on name clashes) and document the
entry-point group.
Co-authored-by: MervinPraison <[email protected]>
Presence of the vector_store.provider key (not its truthiness) now marks an
explicit choice, so an explicitly empty ("") or null (None) provider surfaces
the preset error instead of silently degrading to Mem0. Adds parametrized
regression coverage for both falsey values.
Co-authored-by: Mervin Praison <[email protected]>
The as_tool() Handoff conversion in _process_handoffs produces a closure
bound to the source agent (its chat_history/tools/memory). clone_for_channel()
shallow-copied that closure verbatim, so a channel clone would delegate using
the ORIGINAL agent's state, leaking one channel's context into another.
Tag the generated closure with its source Handoff and, during cloning, swap it
back so the clone's own _process_handoffs rebinds it to the clone -- mirroring
the existing _praison_sandbox_tool drop-and-regenerate pattern. Adds a clone
regression test asserting the clone's converted tool is bound to the clone, not
the origin.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
to_knowledge_config() emitted reranker.config.model without a provider.
mem0's RerankerConfig requires a provider and Knowledge._prepare_mem0_config
strips all but provider/config, so a provider-less rerank_model was silently
dropped. Derive the provider from a "provider/model" prefix (falling back to
the model string) so the requested reranker is instantiated.
Co-authored-by: Mervin Praison <[email protected]>
AgentTeam stored self.llm and nothing read it, so members silently kept
gpt-4o-mini. AgentFlow(llm=) only reached agents it built itself, never a
ready-made Agent step. Add Agent._apply_default_llm to adopt a container
default as a fill-in (agents that named their own model keep it), and call
it from both containers.
Co-authored-by: MervinPraison <[email protected]>
Agent(auth="claude-code") was silently billed to the API key because auth=
was dropped on the commonest construction branch (bare model name / no model)
and self.auth was never assigned, so clone_for_channel() always lost it.
- Land auth on the final else branch: bare-name + auth= now builds an LLM
instance that injects the resolved OAuth credentials.
- Assign self.auth so clone_for_channel() forwards it.
- Resolve the subscription provider eagerly at construction so an unknown or
unusable provider raises instead of silently downgrading to API-key billing.
- Default the model to the auth provider's vendor when no model is given.
- Inject subscription credentials in _build_responses_params too, mirroring
_build_completion_params.
- Add regression tests and wire the subscription-auth smoke tests into CI.
Co-authored-by: MervinPraison <[email protected]>
as_tool() returns a Handoff, and its own docstring passes the result into
tools=[...]. But tools= stores objects verbatim and only handoffs= ran
to_tool_function(), so an unconverted Handoff was invisible to both the LLM
tool schema and execute_tool(). Convert Handoff entries found in self.tools
inside _process_handoffs, the first point where the parent agent that
to_tool_function() needs actually exists.
Co-authored-by: MervinPraison <[email protected]>
MemoryConfig declared backends (redis/valkey/postgres) that had no adapter,
so requests were silently redirected to a local SQLite/JSON store, and
"sqlite" was overloaded (Agent built ChromaDB while Memory built SQLite).
- MemoryBackend enum now mirrors the registry (file + registered adapters)
- Unimplemented backends raise a clear error instead of substituting
- "sqlite" now builds SQLite on both Agent and Memory (no rag rewrite)
- Memory provider with no adapter fails loudly; deliberate fallbacks warn
- Drop redis/postgres presets and URL schemes that dropped host/credentials
- register_memory_adapter() remains the escape hatch for custom backends
Co-authored-by: MervinPraison <[email protected]>
Eight of KnowledgeConfig's ten fields never reached the internal config:
chunk_size, chunk_overlap, chunking_strategy, chunker, vector_store,
auto_retrieve, rerank_model and retrieval_threshold were read then dropped.
Also config={...} replaced the whole resolved dict, silently resetting
retrieval_k.
- agent.py: forward every declared field with the keys RetrievalConfig
reads (min_score, not threshold); config= now overrides instead of
replacing.
- feature_configs.py: ChunkingStrategy gains TOKEN/RECURSIVE, FIXED/PARAGRAPH
kept as aliases; declared chunking defaults aligned to the effective ones.
- chunking.py: normalize_chunker_type maps strategy names to real chunkers.
- knowledge.py: chunker reads from config instead of hardcoding recursive/512/50.
- retrieval_config.py: add rerank_model + chunking fields, round-trip them,
and emit a chunker block from to_knowledge_config.
Defaults unchanged so existing users are unaffected on upgrade.
Co-authored-by: MervinPraison <[email protected]>
ToolConfig(retry_policy=RetryPolicy(max_attempts=N)) now caps how many
times a tool body runs. Previously two nested loops consumed two
independent vocabularies (RetryPolicy vs ExecutionConfig) in different
units, so max_attempts was silently overridden by max_retry_limit,
running non-idempotent tools more times than configured.
The effective RetryPolicy is now the single budget; ExecutionConfig's
retries/seconds/fraction spelling is translated into it in one place
(_effective_tool_retry_policy). No behaviour change for users who do not
set retry_policy=.
Co-authored-by: MervinPraison <[email protected]>
An all-or-nothing all(isinstance(t, str)) gate silently left every
tool-name string unresolved as soon as one callable was present, so
tools=[my_tool, 'read_file'] dropped 'read_file'. Resolve per element
via a new resolve_tools_list() helper that passes non-strings through
untouched and reports/strict-checks unknown names exactly as the
all-strings path already does.
Co-authored-by: MervinPraison <[email protected]>
execute_tool_async caught a raised tool exception and returned an untyped
error dict; the caller re-classified it as "unknown", which no default
RetryPolicy.retry_on contains, so async always ran a failing tool exactly
once. Carry the retry verdict across the flattening (same rule as the sync
path: programming errors terminal, everything else retryable) and honour it
in the retry loop.
Co-authored-by: MervinPraison <[email protected]>
Knowledge.memory used provider_mapping.get(provider, "mem0"), silently
substituting Mem0 for any provider outside a hardcoded four-name list —
discarding every adapter registered via register_knowledge_adapter() and
swallowing typos. Match memory.py: keep the table for genuine legacy
aliases and pass everything else to the registry, raising a helpful
make_preset_error when an explicit provider is unknown.
Co-authored-by: MervinPraison <[email protected]>
Prompt caching is not applied automatically. LLM._explicit_cache_breakpoints()
gates Anthropic cache_control injection on prompt_caching being truthy, and
Agent defaults it to None. Document the correct spelling
caching=CachingConfig(prompt_caching=True).
Co-authored-by: Mervin Praison <[email protected]>
Resolves two P1 review findings on the closed-set preset validation:
- approval hyphen aliases (accept-edits, bypass-permissions, full-auto,
dont-ask, ...) were rejected by the new guard even though
PermissionMode.resolve accepts them (-/_ interchangeable). The validator
now normalizes hyphens too, restoring previously-valid construction.
- learn= used case-sensitive, non-stripped == comparisons, so a validated
value like "AGENTIC" or " agentic " fell through to else and SILENTLY
disabled learning. It now uses .strip().lower() like self_improve does.
- resolve()'s preset lookup and the approval deny-set lookup now normalize
whitespace/hyphens too, so every spelling the guard accepts also resolves
(previously " sliding_window "/"read-only" passed validation but the
deny-set lookup produced an EMPTY deny set — the exact read-only-sandbox
bypass this PR exists to prevent).
Adds regression tests for hyphen aliases, case/whitespace-normalized
presets (learn actually enabled), and the read-only deny-set security path.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Replace the single module-level identity pointer (_autocreated_store/
_autocreated_dir) with a per-instance marker attribute stamped on any store
this module auto-creates. A partial teardown that restores only
_default_store without restoring the out-of-band tracking variable could
otherwise misclassify a previously auto-created store as caller-injected and
pin it to a stale session directory (Greptile P1).
Also make the DEFAULT_SESSION_DIR __getattr__ hook honour an explicit module
override consistently via _resolve_default_session_dir(), and fix the
override test's leaky monkeypatch (the lazy __getattr__ made setattr restore
a computed value as a real global, leaking into later tests). Adds a
regression test for the restored-store scenario.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
The session module's __init__.py and README.md documented
Agent(name=..., session_id=...), which Agent.__init__ rejects with TypeError.
session_id/user_id/db were consolidated into MemoryConfig, and prompt_caching
was never a parameter (caching is applied automatically per model capability).
Fixes the docs to the working spelling and adds a two-sided guard test.
Co-authored-by: MervinPraison <[email protected]>
Address two P1 review findings on the sub-agent transcript relocation:
- Partial migration: legacy per-agent records were only consulted when the
parent held no current-format histories, so a second agent still living in
a tagged legacy record silently resumed empty. A new _merged_agent_histories
helper always layers legacy under current-format entries (current wins), used
by both single and bulk restore paths.
- Concurrent saves: _store_agent_history read the whole map outside the store
lock then replaced it, letting a concurrent writer under the same parent drop
another agent. It now re-reads the freshest map before each write and verifies
no other key regressed, retrying a bounded number of times.
Adds regression tests for partial-migration recovery and concurrent-save
preservation.
Co-authored-by: Mervin Praison <[email protected]>
The mistyped-command guard used difflib ratio >= 0.8 alone, which also
matches legitimate one-word prompts that are plurals or extensions of a
command word (`tests`->`test`, `runs`->`run`, `server`->`serve`). Those
wrongly exited 2 before reaching the bare-prompt path.
Drop prefix-related matches: an extension contains the command as a prefix
(or vice-versa) whereas a genuine typo (`deploi` vs `deploy`) does not, so
real prompts reach `run` while typos still exit 2 with a suggestion.
Co-authored-by: Mervin Praison <[email protected]>
Seven Agent(...) preset params (output, execution, context, autonomy,
approval, learn, self_improve) silently fell back to defaults on a typo'd
preset name. Worst case: approval="read_onl" produced an empty deny set,
allowing all 17 dangerous tools instead of the intended read-only sandbox.
Wire the existing make_preset_error helper to these params via a small
validate_preset_string() in parse_utils.py plus a compact
_PRESET_STRING_PARAMS table and _validate_preset_params() loop run at the
top of the consolidated-params block, before every fast path that could
swallow the value. Free-form string params (knowledge, guardrails, skills,
runtime, auth) are deliberately excluded.
Co-authored-by: MervinPraison <[email protected]>
An empty list/tuple resolved to None (no-op) on main, but the new
fail-loud guard rejected it with TypeError. Exempt empty collections
so an empty opt-in stays backward-compatible; genuinely unsupported
values still raise. Adds a regression test.
Co-authored-by: Mervin Praison <[email protected]>
ExecutionConfig.parallel_tool_calls now uses None as an internal "not set"
sentinel (normalised to bool in __post_init__) so Agent._merge_parallel_tool_calls
can tell an explicit False from an omitted default. A ToolConfig(parallel=True)
alongside an explicit ExecutionConfig(parallel_tool_calls=False) now raises
TypeError instead of silently enabling parallel tool calls (Greptile P1).
Co-authored-by: Mervin Praison <[email protected]>
Sub-agent transcripts were persisted as separate top-level sessions named
"{session_id}_{name}:{role}", whose sanitised path collided with real user
sessions of the same name (e.g. Session("chat") + Agent("support", "agent")
-> chat_support_agent.json). An ordinary close()/save_state() then silently
overwrote an unrelated user's conversation and could leak it back into the
agent on restore.
Move sub-agent histories into the parent session's own
metadata["agent_histories"][agent_key], removing the second namespace
entirely. Legacy tagged records migrate forward on save; untagged pre-tag
records are restored only behind PRAISONAI_SESSION_LEGACY_AGENT_KEYS=1
(ambiguous by construction) and are never deleted.
Co-authored-by: MervinPraison <[email protected]>
DEFAULT_SESSION_DIR was evaluated at module import time, freezing the
store root to whatever PRAISONAI_HOME resolved to during the first import.
Any later override -- a container exporting PRAISONAI_HOME after the
package was imported, or a test monkeypatching get_sessions_dir -- was
silently ignored, so the store read, wrote and deleted sessions in the
wrong directory.
- Resolve the directory live in DefaultSessionStore.__init__ via
_resolve_default_session_dir().
- Keep DEFAULT_SESSION_DIR readable/assignable via PEP 562 __getattr__.
- Rebuild the singleton when the resolved directory changes, while
never second-guessing a store a caller injected.
- Fix two repo tests that set the unread PRAISONAI_SESSIONS_DIR env var
and reset the wrong singleton attribute.
- Add tests/unit/session/test_session_store_dir_resolution.py.
Co-authored-by: MervinPraison <[email protected]>
Add behavioural coverage for require_approval's sync and async wrappers.
The existing approval suite only asserted registration metadata, so both
denial `raise PermissionError` lines could be deleted with the suite green.
New tests install a denying backend, call a decorated tool, and assert the
PermissionError plus that the wrapped body never ran (side-effect probe).
Positive controls, modified_args, fail-closed, and async-context cases
included.
Co-authored-by: MervinPraison <[email protected]>
The praisonai dispatcher only introspected the `run` subcommand's options,
never the root callback's global flags (`--output-format`/`-o`). As a result:
- `praisonai --output-format json version` misrouted to the legacy engine,
dropped `version`, sent `json` to an LLM, and exited 0.
- A typo'd verb like `praisonai deploi` fell through to the bare-prompt path,
was billed to an LLM, created a session, and exited 1.
Fix, entirely in the dispatcher:
1. Introspect the root callback and union with `run`'s options for argv
tokenisation, so `--output-format` routes correctly.
2. Hoist global-only flags ahead of `run` when synthesising a `run` call
(Click accepts group options only before the subcommand). Options declared
by both (`-o`, `-v`) stay with `run`, preserving existing semantics.
3. Add an unknown-subcommand guard: a lone positional that difflib rates a
close match (>= 0.8) to a registered command exits 2 with a suggestion,
guarded so genuine one-word prompts still work.
Co-authored-by: MervinPraison <[email protected]>
Agent(guardrails=<validator object>) previously fell past every dispatch
branch and was dropped with no exception or warning, so the agent ran with
zero enforcement. Adapt protocol-conforming guardrail objects (and lists of
them) into GuardrailChain, raise TypeError on unsupported values instead of
storing something that never runs, and wire _tool_call_guardrails so
validate_tool_call is no longer dead code.
Co-authored-by: MervinPraison <[email protected]>
ToolConfig.parallel is now a deprecated alias (None = unset) that feeds
ExecutionConfig.parallel_tool_calls, which every public entry point forwards
to the LLM. A bare ToolConfig() no longer silently disables an explicit
parallel_tool_calls=True, and conflicting explicit spellings raise TypeError.
Co-authored-by: MervinPraison <[email protected]>
Previously any --transport value other than exactly "stdio" fell through
to the SSE branch, so a typo (e.g. `stido`) or the advertised-but-
unimplemented `http-stream` silently started a listening socket instead
of a stdio pipe. Add explicit validation rejecting unknown/unimplemented
transports with exit code 2 before any branch can bind.
Co-authored-by: MervinPraison <[email protected]>
- Workflow YAMLs run through YAMLWorkflowParser, which has no cli_backend
wiring; the framework-level compatibility check passes for 'praisonai'
and the field was silently dropped, running the workflow on the native
LLM path. _build_yaml_workflow now fails fast with a clear error,
mirroring the existing tool_timeout and CLI-capability rejections.
- _chat_via_cli_backend: post-success hook emission and chat-history
bookkeeping failures are contained (logged) instead of being reported
as backend-execution failures, which invited callers to retry work the
external CLI already completed; the failure hook now receives the
backend's error result (with its audit metadata) instead of None.
- Tests: workflow-YAML rejection, post-execution containment, and
error-result metadata propagation.
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
The precise duplicate-add detection introduced in this PR classifies a
schedule add as successful only when the response contains the producer-owned
confirmation "Schedule '<name>' added". The pre-existing timezone-surface test
stubbed schedule_add with a generic "Schedule added" string lacking the job
name, so the new guard correctly treated it as a rejection and the CLI exited
non-zero. Update the stub to return the real success shape; the guard is
unchanged (test was stale, not the code).
Co-authored-by: Mervin Praison <[email protected]>
- schedule add: failure detected as absence of the producer-owned
success confirmation instead of substring matching, so a schedule
literally named 'Error monitor' no longer exits 1 on success
- --backend-timeout: nan/inf/-inf rejected before the job is created
(inf previously raised OverflowError AFTER the schedule was stored);
guard tolerates direct calls passing Typer OptionInfo defaults
- backend gate: go-path state_updates (cursor/watermark) now persist
only after the backend turn succeeds — a policy block, resolution
failure, timeout, or backend error no longer advances the cursor past
unprocessed work; skip-path persistence unchanged; regression test
- CLI session: result validated and resume state recorded BEFORE the
cancellable hook await, so cancellation during the hook cannot lose
is_resume for the next turn (and the lock is already released)
Cross-event-loop lock coordination (two OS threads sharing one Agent,
each with its own loop) is documented as out of scope: sharing an Agent
across threads is unsupported for the wider Agent state; the re-key
path preserves per-loop serialisation, which is the supported usage.
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
- CLI-backend session lock: asyncio.CancelledError bypasses 'except
Exception', which could leave the per-session lock held forever; a
dedicated handler now releases (guarded by a lock_held flag so a lock
re-acquired by a concurrent turn is never stolen) and re-raises. The
same flag guards the generic exception path.
- Backend pre-run gate now mirrors the native path's state handling:
persisted gate state is loaded, passed to state-aware gates, and
decision.state_updates persist on both skip and go — a backend
monitor job keeps its cursor/watermark across ticks.
- schedule add --backend-timeout: positive sub-millisecond values no
longer collapse to 0 (which the executor replaced with the 300s
default); rounded up to at least 1ms.
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
The defensive conflict check marked the run failed but returned before
_audit_output and _maybe_deliver_failure, unlike every other backend
failure path — audited deployments would lose the record and the owner
notification. Now consistent; regression test asserts the audit.
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
A duplicate-name add creates nothing and (post the job_created guard) mutates
nothing, but still printed success and exited 0 — misleading scripts/CI. Treat
the 'already exists'/'Error' responses as failures so a rejected add exits 1 in
both text and --json modes. Adds a regression test asserting no mutation + exit 1.
Co-authored-by: Mervin Praison <[email protected]>
- all backend-branch _audit_output calls now go through
asyncio.to_thread, matching the native-agent failure paths so slow
audit storage cannot block ticker/delivery coroutines
- 'cli_backend: null' in YAML behaves as unset (backward compatible);
the fail-closed error fires only for a non-null value that cannot
resolve
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
ef6bb47b started scrubbing secret ENV lines on capture via
container.commit(..., changes=scrub) -- a real docker-py kwarg -- but the
suite's _FakeContainer.commit() still took only (repository, tag). Every
capturing provision raised TypeError, fell back to ephemeral, and left
commit_log/list_captures empty: the IndexError and 0==N failures in CI.
Production code is correct; the fake had drifted from the API it mocks.
Widened the fake to the real signature so a successful commit stays a
successful commit.
Co-authored-by: Mervin Praison <[email protected]>
Addresses the remaining review findings:
- --command and --backend are mutually exclusive at the CLI; the
executor fails loudly on persisted jobs carrying both (defensive)
- backend jobs now evaluate the pre-run condition gate (skipped, no
subprocess when nothing to do; gate context appended to the prompt)
and pass the message through RunPolicy.scan_prompt when configured,
matching native-agent jobs
- CLI session establishment is serialised per session id with an
asyncio lock (cross-event-loop safe) so concurrent turns cannot
spawn two fresh CLI conversations for one session
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
Addresses the three review findings:
- schedule add: trusted post-add options apply only when this invocation
actually created the job — a duplicate-name rejection can no longer
reconfigure a live schedule
- YAML cli_backend that cannot resolve now raises with remediation
instead of silently running the native LLM path
- backend resolution/timeout/launch failures now write the durable
audit record before failure delivery (all three early-return sites)
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
Greptile P1: the retry loop still mutated self._current_profile on
rotation, and mark_failure/mark_success plus token-model attribution read
that shared pointer. Concurrent requests on one LLM instance could mark
the wrong profile failed/healthy or misattribute cost.
Rotation is now a call-local: _handle_retry_exception takes the active
profile in and returns the (possibly rotated) profile out; both retry
loops keep it in a local and use it for mark_failure/mark_success.
self._current_profile stays pinned to the initial profile. Token usage
is attributed from the serving model on the response (race-free), so the
now-unused _active_profile_values() is removed.
Co-authored-by: Mervin Praison <[email protected]>
Schedule a job that runs its message as one headless turn through a
registered coding-CLI backend (claude-code, codex-cli, gemini-cli,
grok-cli): new ScheduleJob.backend/backend_options fields, an executor
action that resolves backends lazily via the sanctioned _code_bridge
seam (bot never hard-depends on praisonai-code), and
'praisonai schedule add --backend/--backend-cwd/--backend-timeout'.
Output is bounded, failures flow through run records, audit and
failure delivery, and the pinned model is passed to the CLI.
Backend fixes shipped alongside, all previously silent:
- timeouts now return CliBackendResult(error=...) instead of escaping
as bare TimeoutError (all four backends)
- captured stderr reaches the error message (codex/gemini/grok)
- codex: 'exec resume <id>' subcommand order; TOML-safe instructions
escaping; -m model flag; -C honors cwd
- gemini/grok/claude: model flag + cwd threading
- CliSessionBinding.is_resume is now set on the second turn of a
session, activating the resume branches in all four backends
- YAML 'cli_backend:' resolves through _resolve_yaml_cli_backend into
the cli_backend kwarg instead of misrouting into the runtime
registry (shipped example now works)
- 'praisonai backends' registered as a Typer command (standalone
praisonai-code installs previously could not reach the legacy path)
Tests: 19 new (backend action + serialisation round-trip + fixes);
existing scheduler/backend suites verified unchanged vs main;
check_bot_code_imports gate passes.
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
_switch_to_profile wrote the rotated profile's api_key/base_url/model
onto the shared LLM instance. With one instance serving concurrent
requests (asyncio.gather on a shared Agent, or a cached per-session
Agent), a failover rotation for request A changed the credentials and
model that request B read on its next completion build, silently
sending B's traffic to the wrong provider. Rotation is now a per-call
concern: the retry loop threads the active profile through request
kwargs, and instance attributes keep the initial profile values.
Token tracking follows the profile actually used for the call.
Closes#3613 (gap 2).
_release() called asyncio.run() directly, so GC on a thread already driving
an event loop raised RuntimeError before shutdown ran -- the instance leaked.
Bridge the loop the way SharedCompute._run_sync does (offload to a private
thread when a loop is live) so teardown survives either case.
DockerSandbox.execute() labelled its ephemeral --rm containers praisonai=managed,
the sole input to DockerCompute.list_instances(). Those praisonai-<uuid> names
then surfaced in `managed ps` but `managed stop` resolves praisonai_<id> and
could never reclaim them. Relabel as praisonai=sandbox-exec; the timeout kill
uses --name and is unaffected.
Adds a regression test proving _release reclaims when invoked on a live loop.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Mervin Praison <[email protected]>
Both found by adversarial review, both pre-existing, both leaks.
A TIMED-OUT execution left its container running. The handler called
proc.kill(), which ends the docker CLI while the container it started
carries on. The container also had no --name, so it got a random Docker
name -- neither the timeout handler nor `praisonai managed ps` could
find it, and the user was left with something they could see in
`docker ps` and had no supported way to stop. It is named and labelled
now, and the timeout kills the container rather than the client.
The sibling run_command() path already did this, with the comment "Kill
the container, not just the client". execute() never got the same
treatment; the two paths have been quietly different ever since.
run_on= NEVER RECLAIMED ITS INSTANCE. ComputeManagedAgent defaults to
keep_alive=True, which is right -- an instance should survive between
turns -- but it registered no finalizer, so it also survived the process.
For docker that was a container per script; for e2b, modal, daytona,
tenki and flyio it is a billed cloud instance whose only other reaper is
the provider's own idle timer, and docker and flyio have none.
It now uses the same weakref.finalize treatment tools_placement uses for
agents, which covers collection and interpreter exit, and stands down
when the caller shuts down deliberately. The finalizer deliberately does
not close over the backend -- that would keep alive the very object whose
collection it waits for.
Verified from a confirmed-zero baseline: 0 containers after a timeout
(was 1), 0 after a run_on= script exits (was 1), and ordinary runs
unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Address two P1 review findings on the YAML adapter/drop-in feature:
- Trust gate bypass: project-local .praisonai/channels/*.py files were
classified by the plugins-scoped _is_project_local() (which checks
.praisonai/plugins), so they executed without PRAISONAI_ALLOW_PROJECT_PLUGINS.
Add _is_project_local_channel() scoped to the channels dir and gate on it.
- adapter field reaching constructor: the loader-only YAML `adapter:` import
ref was passed through into adapter_cls(**init_kwargs), raising TypeError for
adapters that don't accept an `adapter` kwarg. Skip it in the gateway
pass-through alongside platform/token.
Adds regression tests for project-local opt-in enforcement (blocked/allowed)
and the adapter-field exclusion.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Restores the CLI/YAML/Python 3-way surface for custom gateway channels:
- ChannelConfigSchema gains an optional `adapter:` dotted "module:Class"
import string; unregistered platforms are lazily imported, validated
against BasePlatformAdapter, and self-registered before startup.
- BotPlatformRegistry auto-discovers single-file adapters dropped into
./.praisonai/channels/ and ~/.praisonai/channels/, reusing the same
trust gate as single-file plugins.
Backward compatible: absent the adapter key and drop-in dir, resolution
behaves exactly as before.
Co-authored-by: MervinPraison <[email protected]>
The sandbox directory is mounted at /sandbox, so that is the path a user
sees from inside the container and the one they naturally reach for. But
the file API treats "/" as the sandbox root, so the container spelling
was joined onto the root a second time:
write_file("/sandbox/report.txt") -> True
cat /sandbox/report.txt -> No such file or directory
find /sandbox -name report.txt -> /sandbox/sandbox/report.txt
and the reverse direction failed too -- a file the container had just
written to /sandbox/out.txt was invisible to read_file("/sandbox/out.txt")
and absent from list_files("/sandbox"), while read_file("out.txt") found
it. Two spellings of one location, one of them silently wrong, and the
wrong one is the spelling the container itself shows you.
Both now resolve to the same file. Only the exact mount prefix is
stripped, so a genuine subdirectory called "sandbox" is not swallowed --
there is a test for that, because prefix-stripping is the kind of fix
that quietly breaks the case it was not thinking about.
Introduced by the mount added to make writes reach the container at all;
before that, /sandbox did not exist inside it, so nothing could be
addressed either way.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Three valid P1s from adversarial review:
- execute()'s _build_docker_command passed max_processes straight to
--pids-limit, so the default 10 killed the container before python
started (exit 125). Apply MIN_CONTAINER_PIDS floor, matching run_command().
- docker commit scrubbed only a fixed secret-name set. ComputeConfig.env is
public (.praisonai/environment.yaml), so an arbitrary custom secret was
baked into the persistent image. Scrub the union of the known set and the
config's own env names -- the values actually injected into the container.
- open_in_sandbox/makedirs_in_sandbox referenced os.O_DIRECTORY / O_NOFOLLOW
and dir_fd unconditionally, raising AttributeError on Windows. Gate the
openat walk behind a capability flag; fall back to the resolved-string
guard where openat is unavailable (Docker there runs a Linux VM, so no
host-shared dir exists to race).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Mervin Praison <[email protected]>
Four defects found by adversarial review of the merged consolidation. The
first two are mine.
ESCAPE. The bind mount added so write_file() actually reaches the
container turned a harmless check-then-use into an exploitable race.
safe_sandbox_path() validates a path STRING; the caller then opens that
string -- two syscalls with a gap. Nothing but the host could write to
that directory before it was mounted. Afterwards, code inside the
container could swap a name between a regular file and a symlink:
for i in $(seq 1 6); do (while true; do
rm -f /sandbox/notes.txt; ln -s /etc/passwd /sandbox/notes.txt
rm -f /sandbox/notes.txt; : > /sandbox/notes.txt
done) & done
Measured before the fix: an escape to an arbitrary host file on write
attempt 375, and a host secret disclosed on read attempt 4. Arbitrary
host read and write, from code that where_does_it_run() calls "a Docker
container".
No amount of stricter string checking fixes this -- anything verified
before the open can be invalidated after it. So the file is never
re-opened by name: open_in_sandbox() walks the path one component at a
time against an open directory descriptor with O_NOFOLLOW at each step,
so a symlink substituted at any level fails the open rather than
redirecting it. Re-running the attack: host file untouched, secret not
disclosed, ordinary writes unaffected.
LEAK ON EVERY EXIT. tools_run_on= releases its sandbox from a weakref
finalizer, which usually runs as the process ends -- and by then
concurrent.futures has set its own atexit flag, so run_in_executor raises
"cannot schedule new futures after interpreter shutdown". The failure was
swallowed and logged as a successful release, so every run left a
container up while reporting it had cleaned up. Docker and Fly.io have no
idle reaper, so those were indefinite.
_offload() now falls back to calling inline when the executor is gone,
which is correct there: no event loop is left to starve. Unrelated
RuntimeErrors still propagate. DockerCompute was never converted to the
shared base in the earlier extraction, so it had the same bug in four
more places; it inherits now. Teardown reports failure instead of
claiming success, since a failed release means something is still
running -- or still billing.
SECRETS IN A COMMITTED IMAGE. `docker commit` preserves Config.Env, so
the model API keys forwarded into a container were baked into an image
that persists for days and is readable by anyone who can run `docker
inspect`. The capture exists to reuse installed packages, not the
credentials that happened to be present while installing them, so the
commit now scrubs them.
FORK BOMBS. run_command() accepted max_processes and ignored it while
the other execution path applied it. Docker's --pids-limit is per
container, so unlike RLIMIT_NPROC it means what it says -- but the
default of 10 was chosen for the rlimit world and kills the container
before the shell finishes starting. The cap has a floor a container can
live within; it still bounds a fork bomb, it just stops the bound from
being the bug.
Also drops `native` from the not-a-boundary list: it aliases sandlock,
which applies Landlock and seccomp, so the two spellings of one backend
were giving opposite security advice.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Seed the outbound carrier with provider headers first, then inject the
active trace context last so a fresh traceparent/tracestate always wins
over any stale provider-supplied values (Greptile review on #4103).
Co-authored-by: Mervin Praison <[email protected]>
Extend the dependency-free GatewayTraceHook seam so an exporter can continue
an upstream caller's distributed trace and propagate context downstream:
- stage() gains an optional parent_carrier (inbound traceparent/tracestate)
so a stage can be entered under an extracted parent span context.
- inject_context()/extract_carrier() no-op defaults let egress call sites
write the active traceparent and ingress boundaries continue a caller's
trace. Real extract/inject lives in the OTel exporter plugin.
NullGatewayTraceHook stays the zero-cost, OpenTelemetry-free default:
inject_context leaves the carrier untouched and extract_carrier returns None.
Additive and backward compatible.
Co-authored-by: Mervin Praison <[email protected]>
The error branch in _parse_tool_call_arguments (PR #4075, commit 991b2ff28)
intentionally reads the real tool name from tool_call["function"]["name"] so a
malformed/truncated arguments string is reported back to the model for the
correct tool instead of aborting the turn with a fabricated name. The stale
test still asserted the old "unknown_function" behaviour, causing a pre-existing
test-core failure unrelated to the docker-sandbox changes on this branch.
Update the assertion to the current contract and add a case proving the
"unknown_function" fallback still applies when no name is recoverable.
Co-authored-by: Mervin Praison <[email protected]>
run_in="docker" goes through execute(), which mounted only its own
script under /code -- so it could not see files write_file() wrote and
anything it created vanished with the container. Mount the sandbox dir
and run the script from there, matching run_command(). Adds a regression
test proving execute() reads a written file and its output persists.
Co-authored-by: Mervin Praison <[email protected]>
It said "backends extracted from the praisonai wrapper", which stopped
being the whole truth when the seven compute providers moved in. The
package now holds both shapes: eight sandboxes (one object IS one
environment) and seven compute providers (one object hands out many).
The distribution name stays. "sandbox" is imprecise for a package that
also contains fleet managers, but it is published, it appears in every
install hint and extra, and renaming a released package costs more than
the imprecision does. The precision lives in the subpackage instead:
praisonai_sandbox.compute is unambiguous about which shape you are
importing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two user-visible defects, found by comparing the two docker
implementations now that they sit in one package.
WRITES WENT NOWHERE. write_file() writes into a host temp directory, and
`docker run` never mounted it. So the write reported success, the file
landed outside the container, and reading it back failed:
write_file("/data.txt", ...) -> True
run_command("cat /data.txt") -> exit 1, No such file or directory
Nothing carried between commands either, since each one got a fresh --rm
container with no shared storage. The directory is now mounted at
/sandbox and commands run there by default, so a write is visible to the
next read and to the next command.
LISTING LEAKED THE HOST PATH. list_files() returned entries like
"/../../../../../../private/var/folders/.../praisonai_sandbox_xxx/a.txt".
On macOS the sandbox directory comes back as /var/folders/... while
os.walk reports /private/var/folders/... -- /var being a symlink -- so
the relative-path computation escaped upward and handed back the real
host location. Both sides are resolved before comparing now, and any
entry that still escapes the root is dropped rather than reported.
Isolation is unchanged and tested: the host filesystem stays invisible,
only the sandbox directory crosses.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The C13 sandbox import gate flags any `from praisonai` line in
praisonai_sandbox/. The moved compute providers still documented the
legacy `praisonai.integrations.compute.*` alias in their docstring
examples, tripping the gate and failing test-core, test-core (sandbox)
and smoke. Point every example at the canonical
`praisonai_sandbox.compute.*` path (the old path is now only an alias),
which both satisfies the gate and gives users the correct import.
Co-authored-by: Mervin Praison <[email protected]>
- praisonai-sandbox[all] now includes tenki and flyio, so a user who runs
`praisonai-sandbox[all]` and selects tools_run_on="tenki" gets the SDK
instead of a missing-dependency error (Greptile P1).
- _compute_bridge _EXTRA_HINTS now point at praisonai-sandbox[<vendor>] rather
than the heavy praisonai wrapper — the whole reason the providers moved
(58 vs 143 resolved packages) — and cover the previously-missing tenki hint.
Co-authored-by: Mervin Praison <[email protected]>
Reaching E2B through `tools_run_on=` required the praisonai wrapper: 20
hard dependencies and 8 sibling packages, including a browser package and
a Telegram bot, to run a command in a sandbox. The identical capability
through `run_in="e2b"` needed only praisonai-sandbox[e2b]. Measured, that
was 143 resolved packages against 58, for the same vendor.
The seven compute providers now live in praisonai_sandbox/compute/,
beside the sandbox backends they share a vendor with. That package was
already the right shape for this: four hard dependencies and one optional
extra per vendor, sitting directly above core. Nothing needed inventing --
the extras grid already existed and was already correct.
Verified with the wrapper made genuinely unimportable: providers resolve
and a real Docker container runs. That property now has a test, so the
providers cannot quietly drift back behind a dependency users should not
need.
Compatibility. The old modules alias the new ones through sys.modules
rather than re-exporting from them. A plain `from ... import X` shim
looks equivalent right up until someone monkeypatches a module-level name
through the old path -- the patch lands on the shim and the real module
never sees it, which is exactly what the wrapper's docker-capture tests
do. Aliasing keeps one shared module object, so every existing import,
attribute and patch keeps working.
tenki's SDK pin follows its implementation; flyio needs none, since it
speaks plain HTTP through aiohttp, which praisonaiagents already
requires.
This does not merge the two implementations of the four shared vendors.
That decision is still open, and it is now easier to make because the
pairs finally sit in the same distribution.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
When a provider's SDK was missing, the error told the user what to
install. Three of those instructions were wrong:
praisonai[e2b] did not exist
praisonai[docker] did not exist
praisonai[daytona] existed but was EMPTY -- installed nothing
pip warns about an unknown extra and then carries on succeeding, so the
user followed the official hint, waited for the install, and hit the
identical failure again. That is worse than no hint at all, because it
looks like it is helping.
The wrapper extras are now real and delegate to praisonai-sandbox, which
already pins every vendor SDK correctly, rather than standing up a second
grid of pins to drift against the first.
A test walks every hint, resolves the extra it names against the real
pyproject, and asserts it exists and is non-empty. The only reason this
survived is that nothing checked whether the advice was true.
Rebased onto main and rebuilt rather than merged: this branch and main
had both appended to test_provider_matrix.py, so a textual merge cut a
function in half. Main's copy is taken whole and the one test unique to
this branch re-applied on top.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Compute truncation before emit_run_result in the actions/custom-agent
paths so --output stream-json consumers never receive a contradictory
run.result {ok:true} ahead of the status:"truncated" outcome (exit 2).
Adds a regression test exercising the stream-bridge ordering.
Co-authored-by: Mervin Praison <[email protected]>
auth._PROVIDER_ENV_KEYS retained only each provider's first catalogue
env-var, so providers with multiple credential spellings (together:
TOGETHER_API_KEY/TOGETHERAI_API_KEY, fireworks: FIREWORKS_API_KEY/
FIREWORKS_AI_API_KEY) had their secondary alias dropped. The runtime
(env.py) already honours all aliases, so `auth list`/`status` reported
"not found" for a credential the runtime accepts — the exact drift this
PR set out to eliminate. Now the map keeps all aliases and auth prefers
whichever the user actually set. Adds parametrised coverage over every
multi-alias provider.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Denials now support optional free-text guidance surfaced back to the model
as a course-correction instead of a bare "User denied" dead-end.
- ApprovalDecision gains optional feedback field (backward-compatible).
- ConsoleBackend adds a [d] "deny & redirect" choice capturing one line
of guidance.
- Tool-loop denial handlers fold feedback into the tool result the model
sees next turn (reuses existing result path, no new subsystem).
- InteractiveCLIApprovalBackend now also sets decision.feedback.
Co-authored-by: MervinPraison <[email protected]>
A run that hits its max_iter/max_steps budget returns a non-empty
finalisation summary, which the CLI's _run_succeeded accepted as success —
masking incomplete work with exit 0 and status "success".
The core already records the terminal reason on the agent via
last_stop_reason ("max_steps" on truncation), so consult it on the
in-process run paths that hold the Agent object and report a distinct
tri-state outcome:
- new _run_was_truncated(agent): reads last_stop_reason (best-effort)
- new _report_run_truncated(output, result): preserves the summary text,
emits status "truncated" for --output json, prints a one-line stderr
notice for interactive users, and exits 2 (distinct from failure=1 and
completion=0)
Wired into the actions, custom-agent, and profiled-prompt paths. Genuine
completion and hard-failure paths are unchanged.
Co-authored-by: MervinPraison <[email protected]>
Wire the two remaining parallel provider->env-var maps to the canonical
PROVIDER_ENV_CATALOGUE so key auto-detection, provider inference, and the
auth surface can no longer drift:
- model_resolver._provider_for_model() now resolves via the catalogue
(provider_for_model + env_vars_for_provider), covering openrouter,
mistral, deepseek, xai, together, fireworks, ... instead of returning
None. Keeps the historical eight-provider ladder as an offline fallback.
- auth._PROVIDER_ENV_KEYS is derived from the catalogue at import, with the
historical literal map retained as an offline fallback.
- Add parametrised drift tests asserting every catalogued provider is
resolvable by auth, model_resolver, and env first-run detection, so
future drift fails CI.
Co-authored-by: MervinPraison <[email protected]>
- audit.py: bind sensitive_keys to the built-in redactor via functools.partial
at construction so both default and custom redactors are always invoked with
a single argument, honouring the declared Callable[[Any], Any] contract.
Custom one-arg redactors no longer raise TypeError before _write runs.
- db_session_adapter.py: add per-session init lock so concurrent first access
calls the non-idempotent on_agent_start exactly once, preventing duplicate-key
races in SQL conversation stores. Init locks are cleaned up on delete_session.
Co-authored-by: Mervin Praison <[email protected]>
- security/audit.py: redact sensitive tool_input keys (api_key, token, etc.)
via a default denylist redactor; chmod the log file to 0o600.
- integrations/db_session_adapter.py: guard the four in-memory containers with
a threading.RLock (DB I/O stays outside the lock) to prevent dropped messages
and duplicated on_agent_start under concurrent multi-agent load.
- __init__.py: disambiguate ModuleNotFoundError on e.name so a broken transitive
dependency surfaces the real cause instead of a misleading "has no attribute".
Co-authored-by: MervinPraison <[email protected]>
Address two P1 review findings on the untrusted-request fencing:
- use_system_prompt=False bypass: add a self-describing inline notice
(request_payload_notice) prepended once outside the fence at each
ingress boundary (webhook render_prompt, hook render_template), so the
untrusted-data semantics travel with the payload even when the agent
never receives the system-prompt trust clause.
- deliver_only markup leak: resolve_message gains a fence flag; the
gateway deliver_only path calls it with fence=False so recipients of
direct channel deliveries never see literal fence tags.
Tests updated for the inline notice and unfenced deliver_only output.
Co-authored-by: Mervin Praison <[email protected]>
Address reviewer-flagged issues on the unified schedule management surface:
- schedule_update now clears last_run_at on cadence change so a new at:/every/
cron schedule is evaluated fresh (a previously-run job updated to at: was
treated as already-fired and never ran).
- CLI pause/resume/update/remove/delete mutate the exact record resolved by id
(via store.update/store.remove) instead of round-tripping through the
name-based tool, so duplicate names cannot cause the wrong job to change.
- schedule list --json emits a single valid JSON document ({store, daemon})
instead of mixing a JSON array with the daemon's human-readable table.
- schedule describe consults the canonical store first, so store-backed jobs
authored by schedule add can be inspected; falls back to the daemon.
Adds a regression test for the cadence-reset fix. Full scheduler suite passes.
Co-authored-by: Mervin Praison <[email protected]>
Address final-review findings on the unattended-run wiring:
- schedules_runner: on the canonical store, claim_due advances last_run_at at
claim time, so raising in on_trigger still consumed the occurrence. Only
start the poll loop when an executor was built; otherwise leave the loop
unstarted so no due job is claimed/consumed (P1: atomic claim consumes
failed occurrence).
- schedules_runner: drop the misleading "process-global registry" claim from
the resolver docstring — no such registry exists in host embedding; message
jobs are recorded failed (auditable) and command jobs run (P1: agent
resolution).
- gateway server: rebuild RunPolicy each tick from the current _loaded_config
so a hot-reloaded scheduler: block applies without a process restart
(P1: reloaded policy remains stale).
- test: replace the tautological on_trigger stub test with one that asserts
the real bridge does not start the loop when no executor is available.
Full scheduler unit suite: 307 passed, 8 skipped (no regressions).
Co-authored-by: Mervin Praison <[email protected]>
Addresses Greptile P1 on PR #4088: resolve_launch_host() previously wrote the
process-wide PRAISONAI_LAUNCH_AUTH_TOKEN before the no-agents / missing-deps
guards, so a launch that never started a server could still flip global auth
and retroactively 401 an already-running endpoint. The bind guard now runs only
after both paths are committed to serving. A generated token is also reused for
the process lifetime so a later non-loopback launch cannot mint a fresh token
and lock out an endpoint already serving with the previous one.
Co-authored-by: Mervin Praison <[email protected]>
- run_stream() now records a terminal RecipeResult (success/failed/policy/
missing-deps/dry-run) via best-effort history, closing the streaming gap
- recipe runs: label the identifier column "Run ID" and align judge guidance,
since `recipe judge` consumes the run_id (the index has no trace_id)
- legacy praison_ai: use direct attribute access (removes Ruff B009)
- tests: add streaming-persistence coverage; legacy test now exercises the
wrapper-bridge resolution path the legacy branch actually uses
Co-authored-by: Mervin Praison <[email protected]>
Addresses reviewer P1s in github_commit_and_push:
- Safety refusals now run BEFORE staging a commit, so a refused push leaves
pending changes intact and a retry can succeed.
- Existing local target branches are switched to with a plain checkout instead
of `checkout -B`, so their local-only commits are not discarded.
- Divergence (force-push) refusal is factored out and applied to every branch,
including agent-prefixed ones, and is never overridable.
Co-authored-by: Mervin Praison <[email protected]>
Defect 1: gateway now constructs a default RunPolicy (deliver-on-failure,
prompt scan, denied toolsets) with optional gateway.yaml `scheduler:` overrides,
so failed scheduled jobs notify the owner and unattended tool downscoping fires.
Defect 2: the host-app schedule bridge now executes due jobs via
ScheduledAgentExecutor; when no executor can be built it raises so ScheduleLoop
leaves the job due instead of silently absorbing it.
Defect 3: the BotOS scheduler path now enforces pin_model via the canonical
_check_model_drift before agent.chat — a drifted pinned job takes no model turn
and is recorded failed with the drift reason.
Co-authored-by: MervinPraison <[email protected]>
Wire the existing untrusted-content fencing at the HTTP ingestion boundary so
webhook/hook payloads arrive labeled as data, not instructions:
- Add reusable wrap_request_payload() + <external_request_payload> fence
constants to tools/trust.py (ingress counterpart to wrap_if_external).
- Extend the system trust clause to name the new fence.
- Fence payload-derived content in the webhook channel render_prompt: template
interpolations and the raw-body default are fenced; the no-prompt case gains a
fixed operator prefix so attacker text is never the sole instruction.
- Fence payload interpolations in the gateway hook resolve_message; operator
template text stays outside the fence.
Fence closers embedded in payloads are delimiter-escaped to prevent breakout.
Co-authored-by: MervinPraison <[email protected]>
Address CodeRabbit/Greptile feedback: the prompt-only regression test
previously never asserted that execution completed or that the agent
branch ran, so it could pass while the job stayed queued. Now records
the agent-branch call and asserts a 200 result of "agent-result".
Co-authored-by: Mervin Praison <[email protected]>
- Persist every RecipeResult via history.store_run at the end of recipe
core.run(); gated by PRAISONAI_RECIPE_HISTORY (default on), write
failures never fail the run (logged and ignored).
- Add `praisonai recipe runs [name] [--status] [--session] [--limit]`
to the Typer app, rendering recipe/version/status/when/session/trace-id
and delegating to history.list_runs.
- Fix the broken legacy import in praisonai-code by routing through the
wrapper bridge to the real praisonai.cli.features.recipe module.
- Add unit tests for persistence, env flag, safe write failure, status
filtering, registry independence, and clean handler import.
Co-authored-by: MervinPraison <[email protected]>
Agent.launch served an unauthenticated prompt-execution HTTP endpoint on
0.0.0.0, while Agents.launch enforced PRAISONAI_LAUNCH_AUTH_TOKEN. Factor the
request authoriser and a fail-closed bind guard into a shared
launch_security module and apply it to both paths:
- Agent.launch HTTP route now returns 401 without the bearer token.
- Both paths auto-generate and print a one-time token when bound to a
non-loopback host keyless (loopback keyless dev flow unchanged).
- Default host changed 0.0.0.0 -> 127.0.0.1 for both launch methods.
- Bearer compare is now constant-time.
Co-authored-by: MervinPraison <[email protected]>
Make store-backed jobs first-class in the CLI so what `schedule add`
writes is what `list`/`delete` and the new subcommands read.
- CLI: `list` now merges store jobs (labeled [store]) with daemon
schedulers; add `runs`, `pause`, `resume`, `update`, `remove` operating
on the ScheduleJob store; `delete` finds store jobs first; `add --once`.
- Agent tools: add `schedule_pause`, `schedule_resume`, `schedule_update`;
expose `once` on `schedule_add` (maps to delete_after_run). Registered
in TOOL_MAPPINGS for lazy string resolution.
Scope: kept to praisonaiagents + praisonai per AGENTS.md. `schedule tick`
requires the Tier-2 praisonai-bot executor and is intentionally not added.
Co-authored-by: MervinPraison <[email protected]>
Refuse autonomous pushes to the default/protected branch, foreign-author
branches, and diverged remotes (never force). Auto-create praisonai/ branch
when on default. Opt-in override via allow_unsafe_branch / env.
Co-authored-by: MervinPraison <[email protected]>
The runs API declared recipe_name/recipe_config on JobSubmitRequest and the
executor implemented the recipe branch, but the router never copied those
fields into the Job — so POST /api/v1/runs {"recipe_name": ...} silently ran
nothing recipe-related. Copy the fields through and add regression tests.
Co-authored-by: MervinPraison <[email protected]>
`docker` is implemented twice -- once as a compute provider, once as a
sandbox backend -- and the two defaults had drifted a minor version
apart. Same word, different container:
tools_run_on="docker" -> Python 3.12.14
run_in="docker" -> Python 3.11.16
Nobody decided that. Two independent implementations of one idea simply
diverged, and unifying the vocabulary made the divergence easier to reach
rather than harder, because both spellings now look like the same thing.
The sandbox side moves to python:3.12-slim to match, along with the CLI,
docs and examples that quoted the old default.
The parity test compares the values a caller actually gets --
SandboxConfig.docker().image against ComputeConfig().image. An earlier
version of it scanned the DockerCompute docstring, which meant it passed
while the defaults were genuinely 3.11 and 3.12 apart. A test that cannot
fail is worse than no test, because it is counted as coverage.
Rebased onto main and rebuilt rather than merged: this branch and main
had both appended to test_provider_matrix.py, and a textual merge cut a
function in half. Main's copy is taken whole and the one test unique to
this branch re-applied on top.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Addresses reviewer P1 findings on the async batch/window fixes:
- _depends_on_pending now also detects dependencies expressed via
task.previous_tasks (workflow next_tasks edges are stored as task names),
not only task.context, so a workflow successor is no longer queued in the
same async batch as its predecessor and forced to read an empty result.
- New _deps_failed method extends the failure cascade to the async_execution
path in both the sequential and workflow branches: after a same-batch flush,
an async dependent whose prerequisite failed is marked failed instead of
running with missing upstream context (previously only sync dependents were
guarded).
- TruncateOptimizer re-skips leading orphaned tool results after its
token-budget pop loop, which could otherwise drop an assistant tool_calls
message while retaining its role="tool" result and leave the trimmed
transcript starting on an orphan (Issue #3089).
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
1. Guard the tool-call argument JSON parse in the non-streaming native
loops (openai_client.chat_completion_with_tools / achat_completion_with_tools)
so a malformed/truncated arguments string is reported back to the model
instead of aborting the whole agent turn, matching the streaming path.
Also read the real tool name from tool_call["function"]["name"] in
llm._parse_tool_call_arguments' error branch instead of the never-present
top-level "name" key.
2. Route DefaultSessionStore._enforce_window's window slice through the
existing _trim_preserving_tool_exchanges guard, and skip leading orphaned
role="tool" messages in the context TruncateOptimizer/SlidingWindowOptimizer,
so a persisted/optimized window never starts mid tool-exchange.
3. Split an async task batch on an intra-batch context dependency in
arun_all_tasks (sequential + workflow) so a dependent async task's context
is built after its same-batch dependency completes, not before.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
The generic ComputeManagedAgent forwards image= through ComputeConfig.image,
which Docker/Daytona/Fly read but E2B (used metadata[e2b_template]) and Modal
(hardcoded debian_slim) ignored -- so run_on='e2b', image=... silently booted
the provider default. E2B now takes a non-default image as its template; Modal
builds it via from_registry. The default value leaves both unchanged. Adds
boundary conformance tests asserting the value each SDK actually receives.
Co-authored-by: Mervin Praison <[email protected]>
Every cloud SDK here is synchronous and ComputeProviderProtocol is async,
so each provider grew the same five methods whose entire body is "hand the
matching _*_sync call to a thread":
async def provision(self, config):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, self._provision_sync, config)
Measured: provision, execute, shutdown, upload_file and download_file were
byte-identical across e2b, daytona, modal and tenki -- six identical pairs
per method, about 210 lines of the same four statements.
That was the largest genuinely duplicated block in the project, larger
than all four cross-stack vendor pairs combined. It stayed invisible
because it sits *within* one stack rather than between two, and the
attention had been on the cross-stack pairs, where the real overlap turns
out to be about 6%.
A vendor now implements only the _*_sync half it differs in. Anything a
provider genuinely does differently it overrides, because these are
ordinary methods -- flyio does exactly that, since it speaks HTTP through
aiohttp and is natively async rather than sync-wrapped.
Each unimplemented operation raises NotImplementedError naming the
provider, rather than silently returning a wrong answer.
Net -119 lines. Verified live: docker and modal both still provision and
execute, and the provider matrix passes for all thirteen places.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`run_on="docker"` had its own 321-line managed backend while the other
seven places shared one generic implementation. The justification written
into that file -- it talks to the daemon directly and skips a
provisioning layer -- did not survive being checked.
It named its containers `praisonai-agent-<hex>`, while DockerCompute's
lookup expects `praisonai_<instance_id>`. So:
praisonai managed ps -> shows the container
praisonai managed stop -> ValueError: No docker container found
Listed but unreclaimable, which is the worst combination: the user can
see the thing they cannot stop. Routing through the provider fixes it by
construction, and 321 lines go with it.
Two gaps in the generic backend had to close first, both real:
- `image=` fell into **kwargs and was silently ignored, so asking for a
specific image quietly got the provider's default.
- the failure when a place is unavailable now names the place and
offers tools_run_on=, which needs no daemon.
The generated class is still called DockerManagedAgent -- make_loader
names each backend after its place -- but it is now a ComputeManagedAgent
subclass, so there is one code path for every runtime.
Also adds the conformance tests that would have caught this class of
problem: the four vendors implemented twice must agree on their
conventions. Docker-only parity is exactly what let Daytona drift into
asking for 1024 GiB of RAM.
Verified live: docker provisions, runs a whole agent loop, appears in
`managed ps`, and `managed stop` now reclaims it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
test_hosted_agent_only_accepts_anthropic encoded a pre-run_on= assumption
that modal/e2b were unavailable managed runtimes. The placement feature
intentionally registered them via the backend registry, so HostedAgent now
resolves them. Assert the guarantee that still holds — providers with no
registered backend (openai/gemini) raise a helpful ValueError — instead of
the stale one.
Co-authored-by: Mervin Praison <[email protected]>
Replace inspect.getsource() substring checks with behavioral tests that
mock the Daytona SDK and assert the actual value passed: memory converted
to GiB (2048MB->2, 512MB->1) and upload_file(src, dst) argument order.
Addresses Greptile p2: source inspection let equivalent regressions pass
and harmless refactors fail.
Co-authored-by: Mervin Praison <[email protected]>
Two live defects on the compute side of Daytona, both found by comparing
it against the sandbox side, which had already learned the conventions.
MEMORY UNITS. daytona_sdk documents Resources.memory as "Amount of memory
in GiB to allocate". The compute provider passed ComputeConfig.memory_mb
straight through, so the default configuration asked Daytona for 1024 GiB
of RAM. That is a provisioning failure, not a rounding error. The sandbox
implementation converts correctly and always has.
ARGUMENT ORDER. FileSystem.upload_file(src, dst) takes the content first
and the destination second. The compute side called
upload_file(remote_path, content), so an upload wrote the path into a
file named after the content. The sandbox side calls it correctly.
Neither is a typo in the ordinary sense: both are the same class of
failure, where one of two implementations of a single vendor learned a
convention and the other never did. Nothing compared them, so nothing
noticed. The wrapper's daytona tests only assert provider_name and that
the constructor exists -- nothing exercises provision() or upload.
Tests now assert both conventions on the compute side, because a shared
vendor name is not a shared understanding of that vendor.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The provisioning tests asserted an exact count of one. SharedCompute
provisions LAZILY, so a run that never touches a tool correctly
provisions nothing -- the exact count was asserting when provisioning
happened rather than the property that matters, and it flaked.
The guarantee is that a run never provisions MORE than one sandbox, and
that a three-row batch does not mean three. Verified the weaker assertion
still has teeth: mutating _needs_tools_scope to always open a scope fails
six tests including the batch one.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
An ephemeral place (keep_alive=False) provisions a billable cloud
instance, but execute() only tore it down on the success path -- a
failed write, remote run, or parse left the instance running until
external cleanup. Move shutdown into a finally so teardown is
unconditional; keep_alive=True still keeps the warm instance.
Fixes the Greptile P1 on compute_managed_agent.py.
Co-authored-by: Mervin Praison <[email protected]>
SANDBOX_ONLY was a hardcoded tuple of four names. The sandbox registry
accepts plugins by entry point, so a contributed backend worked with
run_in= and could not be selected with tools_run_on= at all -- the same
hardcoded-list bug just fixed for the compute registry, one layer down.
`capsule` was the live example: it ships from praisonai-plugins, resolves
fine through run_in=, and was missing from the tools_run_on= vocabulary
for no reason a user could see.
The names now derive from the registry, with the original four kept as a
floor so nothing depends on discovery succeeding. Anything the compute
registry already provides is excluded, so docker/e2b/modal/daytona keep
their richer implementations instead of being downgraded to the
one-instance adapter.
SANDBOX_ONLY stays a tuple subclass that re-reads the registry on
membership and iteration, because several call sites predate this and
import it directly.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The phrase table is a literal, so it cannot know about a package
installed later: an external place printed as a bare name
(tools_run_on='demo') while every built-in got a readable phrase. A
provider may now declare `display_name` and have it used in the repr and
in where_does_it_run().
Verified with a plugin installed from outside the repo: it reports
"a Demo cloud sandbox" in both. Declaring it stays optional and the
fallback to the bare name is unchanged, so nothing existing shifts.
That leaves one derived surface still hand-maintained -- the ToolPlace
Literal -- which is inherent to static typing rather than fixable: a type
checker cannot see a runtime plugin. Its union already stays open, so an
unknown name type-checks and only loses autocomplete.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
_PS_PROVIDERS was a hardcoded tuple, so a place contributed by another
package could be provisioned and then never listed or stopped. An
unreclaimable cloud sandbox is a bill nobody sees, which makes this the
one remaining hand-maintained list with a cost attached.
Exclusions are now stated with reasons rather than by omission: local and
subprocess run here and have nothing to reclaim, ssh is someone else's
machine and not ours to stop, sandlock is a process rather than an
instance, and adapter-backed places have no cross-process registry to
enumerate.
_PS_PROVIDERS stays as a module attribute so existing imports keep
working.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Modal's `run_in=` path was broken in five separate ways and nothing
noticed, because nothing had ever run it. Most of what breaks an
integration is checkable for free, so this checks it: for all ten places
the class resolves, satisfies the protocol, exposes coroutines rather
than sync methods (a sync one here does not raise -- it returns a
coroutine nobody awaits, so the call silently does nothing), answers
is_available without throwing, is accepted by tools_run_on=, is accepted
or refused by run_on= according to whether it can host a loop, and is
described in the repr as the place it was actually given.
The one that matters most in practice is the last check: a missing
credential has to fail with something actionable -- naming the key, the
install command or the daemon -- rather than an AttributeError from
inside a vendor SDK. All seven credential-less places pass it today.
Live where the machine allows: docker, subprocess, local and modal
provision and execute for real; the rest skip loudly.
Also removes a latent flake. The batch-provisioning test asserts an exact
provision count, and agents from earlier tests release their sandbox when
collected -- which could land inside the measurement window now that
lifetime is tied to a finalizer. The window is opened after a collection
instead of hoping the timing misses.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
run_on= accepted two names while tools_run_on= accepted twelve. That was
not a fact about the places -- a place that can run a command can run the
agent loop, which is just one more command -- it was an implementation
detail: no backend had been written for the rest.
One generic ComputeManagedAgent wraps any ComputeProviderProtocol rather
than eleven near-identical vendor backends, taking run_on= from 2 places
to 8: anthropic, docker, e2b, modal, daytona, flyio, tenki, novita.
Verified live end to end on Modal -- the whole loop ran there and came
back with the answer, having installed the package remotely first.
Excluded deliberately, with tests asserting they still raise: `local`
would run the agent in your own shell, which is what you get by passing
nothing; `ssh` needs an object, not a name; `subprocess`/`sandlock`
isolate tools rather than host a runtime. `docker` keeps its specialised
backend since it can talk to the daemon directly.
Two details worth keeping:
- the registry loader returns a real subclass, not a factory function,
because HostedAgent's factory path calls issubclass() on it
- files reach the remote machine through a quoted heredoc, since execute()
is the only primitive every compute place has, and the delimiter is
uppercase for the same reason as elsewhere: a lowercase hex delimiter
contains "dd", a blocked command, about a tenth of the time
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Modal is authenticated on this machine, so the path could finally be
exercised. It failed five times in a row, each fault hiding the next:
1. keep_warm / allow_concurrent_inputs were renamed on 2025-02-24 and
the old spellings now RAISE rather than warn. Both generations are
handled by inspecting the installed SDK's signature, since hardcoding
either spelling breaks half the installed base.
2. The decorated function is defined inside a method, which Modal only
accepts with serialized=True.
3. The App was never started. A Modal function only carries the metadata
it needs while its App is running, so .remote() failed with "Function
has not been hydrated". The app.run() context is now held for the
sandbox's lifetime and closed in stop().
4. add_python="3.11" was passed to an image that already ships Python --
the default is python:3.11 -- so the image build failed outright.
5. The default image was pinned to 3.11 while a serialized function is
pickled locally and unpickled inside it, so anyone not on 3.11 got
"defined with Python 3.12, but its Image has 3.11". The default now
follows the running interpreter.
Verified end to end: execute_code(run_in="modal") returns 'from modal'
with exit 0 against real Modal infrastructure.
tools_run_on="modal" was already working and is now verified live too --
provision, uname, a file written and read back across calls, shutdown.
The test asserting a hardcoded python:3.11 default was updated rather
than deleted; it now pins the interpreter-matching rule that replaced it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two of the three registries already discovered plugins by entry point --
praisonai.sandbox and praisonai.managed_backends. The compute registry,
which is where tools_run_on= places live, was a hardcoded dict, so adding
one meant editing core:
_compute_bridge._PROVIDERS the resolver
compute/__init__.py the lazy import
placement.ToolPlace or it gets no typing
cli/commands/managed._PS_PROVIDERS or `managed ps` cannot see it
execution_location._PLACE_WORDS or it prints a bare name
plus the lists in two docstrings
Six edits across two packages, for something that is by nature a plugin.
It now reads praisonai.compute as well, so a provider ships in its own
distribution:
[project.entry-points."praisonai.compute"]
runpod = "praisonai_compute_runpod:RunpodCompute"
Verified end to end with a package installed outside this repo: it
appeared in available_providers(), passed tools_run_on= validation,
resolved, executed, and Agent(tools_run_on="demo") built -- with no
change to any file here. The repr falls back to the bare name, which is
the one remaining hand-maintained list and degrades gracefully.
Providers load on demand, so an installed-but-broken plugin fails only
for the caller that names it. There is a test for that.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The bots-gateway CI shard runs from src/praisonai-bot, whose gateway.yaml was
auto-discovered when these tests invoked `gateway start` with config=None.
Discovery then ran the verify-turn pre-flight (a real agent turn, default-on
per #4042) before the monkeypatched start(), exiting 1 with CI's fake key.
Pass an explicit channel-less config so every start-time pre-flight is skipped
and the command reaches the patched handler deterministically. The exit-code and
watchdog-flag assertions are unchanged — the tests still guard exactly what they
did, just hermetically.
Co-authored-by: Mervin Praison <[email protected]>
The compute-only branch ran before the provider object was normalised, so a
managed backend holding a LocalCompute (provider_name="local") skipped it and
the alias table collapsed "local" to "subprocess" -- describing the
unrestricted shell in the locked-down backend's stronger words. Normalise
first, then apply the compute-only check, and read a backend's compute via the
compute vocabulary since that object is a compute-registry provider.
Co-authored-by: Mervin Praison <[email protected]>
Three issues raised in review of run_on="docker":
- execute()/stream() ran the blocking Docker start-up, install and container
turn directly on the event-loop thread, stalling concurrent async callers.
Offload to a worker thread with asyncio.to_thread.
- keep_alive=False only tore the container down on success, so a failure after
start-up leaked a praisonai-agent-* container. Move teardown into finally.
- Both READMEs showed run_on="docker" raising TypeError while also documenting
it as supported. The typo example now uses e2b, which genuinely cannot host
an agent loop, so the two examples no longer contradict each other.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Mervin Praison <[email protected]>
`run_on="anthropic"` hands the entire agent to a vendor's runtime. This is
the same shape, self-hosted: the loop runs in a local container instead.
The distinction from tools_run_on="docker" is what crosses the boundary:
tools_run_on="docker" your process runs the loop and calls the model;
only shell and file tools go into the container
run_on="docker" the loop itself runs inside, so the model call is
made from the container and nothing on your
filesystem is reachable unless you mounted it
Both name the same place, and that is not an ambiguity -- the parameter
carries the scope. So the two value sets deliberately overlap now, and
the tests say so: a name in both must work in both, a name in only one
must be refused by the other with a message naming the right parameter,
and the object must still report which scope you chose.
Implementation is just a backend registration. run_on= resolves against
ManagedBackendRegistry, so adding DockerManagedAgent there makes the
parameter, the repr, the explanation and the conflict checks all pick it
up with no changes to any of them.
Two things this exposed:
- _backend_places() identified hosted runtimes by CLASS NAME ("Hosted" or
"Anthropic" in it). A self-hosted DockerManagedAgent matched neither,
so an agent whose entire loop ran in a container reported that it was
thinking on this machine. Backends are now asked what they are.
- The runner writes its payload into the container through `docker exec
-i ... tee` rather than an argument, so instructions and API keys never
appear in `docker inspect` or a process listing.
Stated limitations rather than hidden ones: Python callables in tools= do
not cross (same as the hosted runtimes), the container needs the model
API key, and the first run installs the package unless image= supplies
one that has it.
Tests that need a live daemon are marked skipif rather than silently
passing; the rest -- registration, both scopes, config marshalling,
result parsing, the missing-daemon message -- run without Docker.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two gaps found while documenting every accepted value.
"local" resolves differently depending on which parameter carried it:
run_in="local" is collapsed by SandboxManager to the subprocess backend
-- scrubbed environment, blocked commands and paths -- while
tools_run_on="local" is the wrapper's LocalCompute, a plain shell in the
current directory with the full environment inherited and no security
policy at all. Both printed "a separate process on this machine", so the
weaker of the two was described in the stronger one's words. That is
precisely the failure this module exists to prevent, hiding in the one
place the rest of the work did not look. say_place() now takes the
registry that resolved the name.
Resolver aliases were rejected at construction. Validation ran before
alias resolution, so tools_run_on="native" raised "not a known place"
while resolve_compute("native") returned a working sandlock adapter.
tool_places() now includes the alias table it validates against.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
LocalManagedAgent carried its own hardcoded provider list, which is why
LocalAgent(tools_run_on="sandlock") raised "Unknown compute provider"
for a place the rest of the library resolves happily. Routing it through
the merged resolver changes the message to "Unknown place", listing
every place both registries offer.
The behaviour is unchanged -- an unknown name still raises ValueError --
so only the assertion was stale. Added coverage for what the delegation
actually buys: the sandbox-only places now resolve here too.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two coroutines that astart() the SAME team under one asyncio.gather share
the event-loop thread, so the previous threading.local scope state was
shared between them: the second saw the first's depth==1, skipped its own
sandbox, and ran against a sandbox the first could tear down mid-flight.
Switch _tools_scope_depths from threading.local to a contextvars.ContextVar
holding an immutable frozen mapping replaced via .set() (never mutated in
place). A ContextVar is copied per task at gather/create_task time, so each
run gets its own depth map; it stays per-thread too, so sync callers remain
isolated. The frozen value matters -- a copied context shares the reference
to a mutable dict, so in-place mutation would leak straight back across the
tasks the copy was meant to separate.
Adds a deterministic regression test that fails on the old design
(['own', 'borrowed']) and passes now (['own', 'own']) without needing a
live model.
Co-authored-by: Mervin Praison <[email protected]>
The security audit demonstrated `cat $(echo /etc/passwd)` reading the
file end-to-end through the subprocess place: blocked_paths matches
tokens, and any shell indirection -- command substitution, a variable, a
nested sh -c -- hides the path from it. That is inherent to a blocklist
over a real shell, not a patchable pattern, so where_does_it_run() now
says the command list can be worked around rather than implying the
tier contains anything.
Also updates the flagship YAML example to tools_run_on:, lists the four
newly reachable places, and marks which of them are real boundaries
(sandlock is kernel-enforced; subprocess is process separation only).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Mutation testing showed 7 of 14 deliberate breakages left the suite
green. The pattern was consistent: argument validation and repr strings
were well covered, and every path that actually moves work to a sandbox
was not covered at all. Deleting the ensure_tools_placed() call sites --
the feature's entire point -- broke nothing.
Bugs fixed:
- A clone inherited the source agent's bridged tools, which close over
the SOURCE's sandbox, and its backstory already carrying the capability
prompt. Its first turn then attached a second sandbox: two copies of
the prompt, and work split across two machines. The tools are now
tagged so the existing clone filter drops them, and the clone inherits
the pre-attach backstory.
- test_apply_rlimits_sets_memory_limit still asserted RLIMIT_NPROC was
set. It passed on main and failed here, because dropping NPROC is
deliberate. Replaced with an assertion that it is NOT set, plus a new
test that one unsupported limit no longer skips the others -- the
latent bug that made RLIMIT_NOFILE never apply on macOS.
Tests added for what survived mutation:
- every entry point (chat/achat/_start_stream) places tools before the
first turn, asserted by driving the real method
- AgentTeam start/astart/start_for_each provision exactly one sandbox,
counted through a fake provider, instead of poking a private method
- policy_scan_parts expands shell payloads, survives unbalanced quotes,
strips quoted heredoc bodies but NOT unquoted ones, and a blocked
path inside `sh -c` is genuinely refused (asserting the file was not
read, not merely that an error was returned)
- SandboxComputeAdapter, which had zero tests despite being the largest
new file: policy denials report failure rather than exit 0, shell
semantics work, state persists across calls
- SandboxManager constructor introspection, including that the guided
error names `host` and SSHSandbox rather than leaking a raw TypeError
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two reliability bugs the security audit reproduced.
write_file() built a heredoc delimiter from uuid4().hex, which is
lowercase. The security policy substring-matches blocked_commands
against the whole command string, and "dd" is one of them -- present in
9.6% of random lowercase hex strings (measured over 100k). So roughly
one write in ten failed with "Blocked command pattern: dd", for no
reason the caller could see. The delimiter is uppercase now.
The same substring scan also read the heredoc BODY, which is the literal
bytes of a file, not a command. Saving a shell tutorial containing
"rm -rf" was rejected as an attempt to run it; a file mentioning
/etc/passwd was rejected as an attempt to read it. Quoted heredoc
bodies are now stripped before the policy check -- only the quoted form,
since an unquoted heredoc still undergoes shell expansion and its
contents really can execute.
Verified the boundary is unchanged: `cat /etc/passwd` and `rm -rf /`
are still blocked, while all three content cases now write, and 60
consecutive writes produce 0 spurious failures.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Four lifecycle bugs, all found by adversarial validation.
LEAK. An agent and its SharedCompute referenced each other, so a dropped
agent was reclaimed by the CYCLE collector, vanished from the WeakSet the
atexit hook iterated, and its sandbox was never shut down. Gateways clone
an agent per request, so that was one orphaned container per request --
measured: 5 requests, 5 provisioned, 0 shut down.
Fixed by tying lifetime to the agent with weakref.finalize (which also
covers interpreter exit, so the atexit hook and weak set are gone), and
by holding agents WEAKLY in SharedCompute._patched. The weak _patched is
the load-bearing half: while the restore list held agents strongly, the
finalizer kept its own agent alive and could never fire.
Now: 5 provisioned, 5 shut down, 0 leaked.
RACE. close_tools_sandbox() cleared the marker first and shut down after,
outside the lock. A concurrent chat() re-attached in that window and the
in-flight shutdown restored over it, leaving the agent marked as
sandboxed while holding no sandbox tools -- unrepairable, because
ensure_tools_placed()'s idempotent early return sees the marker and does
nothing. It is all under the lock now.
EXIT HANG. The exit path took a process-global lock, so any thread
holding it blocked interpreter exit forever (reproduced: rc=124). The
realistic form was a bounded stall, since the lock was held across
SharedCompute(...) -- which imports the provider SDK.
CONTENTION. That same global lock serialised placement for unrelated
agents (16 concurrent attaches: 0.87s against a 0.05s parallel floor),
and being non-reentrant while held across attach()'s writes to
agent.tools, any subclass with a tools setter that touched placement
would self-deadlock in its own constructor. Locks are per-agent now, and
the only global lock covers a dictionary lookup.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two bugs found by adversarial validation, both silent.
1. AgentTeam handed the model the PRE-ATTACH tool list.
_build_execution_context snapshots agent.tools and passes it to chat()
as an explicit `tools=`, which wins over agent.tools. The sandbox
attached later, inside chat(), so the model got the local tools while
a real sandbox sat provisioned and unused -- every tool ran on the
host. Placement now happens before the snapshot.
It read as flaky rather than broken: in start_for_each() row 1 got
local tools and row 2 got the bridged set, because row 1's chat() had
mutated agent.tools in time for row 2's snapshot.
2. The scope depth was shared class-wide, not per team.
`_tools_scope_state` is a class attribute, so ANY team started while
another team's run was on the stack saw depth==1 and skipped its own
sandbox. A nested team ran on the host; a team sharing an agent with
the outer run executed on the OUTER team's sandbox; two teams in one
asyncio.gather gave one a sandbox and the other nothing. Depth is now
keyed per team, and the key is dropped at zero so the dict cannot grow
without bound on a long-lived worker thread.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Three parameters meant "somewhere else" and none of them said which
somewhere: `run_on=` (tools only, on Flow/Team), `compute=` (tools only,
on LocalAgent) and `backend=` (the whole agent). One concept had two
names and one name had two concepts, so the docs had to keep correcting
the API.
Now two words, each answering a different question:
run_on= the WHOLE agent moves to a managed runtime
tools_run_on= only the tools move; thinking stays here
run_in= one execute_code() call, this call only
Because the two questions have disjoint answer sets, a wrong pairing is
a typo rather than a preference. `run_on="docker"` cannot mean anything
-- Docker does not host agent loops -- so it raises TypeError naming the
parameter the user actually wanted, instead of silently placing work on
a machine nobody chose. Never "last one wins".
Reachability: sandbox-only backends (subprocess, sandlock, ssh, novita)
spoke SandboxProtocol -- one object IS one environment -- while
tools_run_on= speaks ComputeProviderProtocol -- one object HANDS OUT
many. SandboxComputeAdapter supplies the missing half, taking the
registry from 7 places to 11. This finally makes `sandlock` selectable:
kernel-enforced Landlock + seccomp-bpf, the only real local boundary in
the project, previously unreachable.
Shipped bugs found and fixed along the way:
- AgentTeam.astart() never provisioned the shared sandbox at all, so
every async team ran its tools on the host and said nothing.
- start_for_each() provisioned one sandbox per input row: a 50-row
batch paid for 50, and no row could see what an earlier row wrote.
- The re-entry guard set `self.run_on = None` for the duration of a
run, so a concurrent start() observed "no sandbox" and executed
every tool on the host. Now thread-local; placement stays readable.
- SharedCompute.attach() could attach an agent twice (flow + its own),
duplicating the capability prompt and, on non-LIFO teardown, leaving
the agent holding tools bound to a shut-down instance.
- SandboxManager passed config= to every backend; SSHSandbox and
ModalSandbox do not accept it, so they raised before starting.
- RLIMIT_NPROC was applied per *real user ID*, not per sandbox, so
max_processes=10 counted the user's existing processes and the
sandbox could not fork at all.
- blocked_paths stopped matching once commands ran through `sh -c`,
because the path hid inside the shell payload.
Removed ExecutionConfig.code_sandbox_mode. It defaulted to "sandbox"
while no execution path read it, so its own default told every reader
they had isolation they did not have.
where_does_it_run() now names the tools that will NOT move. Only four
tool names are bridged; the capability prompt tells the model the
sandbox is where things happen, so anything staying behind has to be
said out loud.
Migration:
- YAML `run_on:` stays a silent alias -- it is data people already
shipped, and there is no Agent-level run_on: key to confuse it with.
- Python `run_on=` on Flow/Team raises with a directed message; it
would otherwise mean the opposite of `run_on=` on Agent.
- `compute=` warns (FutureWarning, visible to end users per PEP 565).
- `Agent(sandbox=)` is kept, not removed: clone_for_channel() passes
it and it is a legitimate per-agent default. `run_in=` overrides it
per call.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Extract the repeated inline `next((t for t in self.tasks.values()
if t.name == <name>), None)` into a single `_task_by_name` helper and
call it at all 13 sites. Behaviour-identical: first-match by name,
None when absent. No public API or behaviour change.
Co-authored-by: MervinPraison <[email protected]>
Remove the shutil.which("echo") skipif: TemplateInterpolator runs commands
with shell=True, so echo is available as a shell builtin on both cmd.exe and
/bin/sh even without a standalone executable. Normalise output with .strip()
for Windows CRLF instead, restoring coverage. Also drops the now-unused shutil
import and trailing whitespace.
Co-authored-by: Mervin Praison <[email protected]>
Cross-reference validation only inspected step.agent, step.steps, and
step.routes, so undefined agents nested inside the newly accepted bare
runtime-form payloads (route:/parallel:/loop:/repeat:/if:) silently
passed while the runtime parser drops/rejects them (Greptile P1).
- Walk route: value lists (agent-name scalars) and parallel/loop/repeat/if
sub-step dicts (agent: keys), leaving non-agent scalars like
loop: {over: <var>} untouched.
- Regenerate the stale agents.schema.json artefact to match the PR's
broadened WorkflowStep model.
- Add tests for undefined agents in route/parallel payloads and a
loop-over-variable false-positive guard.
Co-authored-by: MervinPraison <[email protected]>
- Async event loop: run_autonomous_async now offloads verification hook
execution to a worker thread via _verification_gate_async so a slow/blocking
CommandVerificationHook no longer stalls concurrent coroutines.
- Durable journal: rewire _record_verification_journal to the real
DurableRunContext.record_verification (append JournalEvent, KIND_VERIFICATION)
instead of a non-existent _run_journal.record API; events now actually persist.
- Goal gate: a failing blocking hook now carries the captured stdout/stderr
summary into last_reason so the next continuation prompt has actionable repair
diagnostics instead of a bare label.
- Tests: +4 covering async thread-offload, durable journal persistence, and
goal-failure diagnostics propagation.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Addresses two P1 correctness gaps from PR review:
- Explicit --resume onto an absent/misspelled/deleted checkpoint now returns
a clear error instead of silently running the workflow from step 1 and
repeating already-completed side effects.
- _definition_fingerprint now hashes agent config values (not just keys), so
semantic edits (instructions/model/tools/condition/routing) invalidate the
checkpoint rather than combining a stale step index with an edited workflow.
Adds two regression tests: missing-checkpoint fail-closed, config-value edit
changes fingerprint.
Co-authored-by: MervinPraison <[email protected]>
Addresses two P1 review findings on PR #4059:
- handoff._cap: capping an oversized recap dropped the actionable NEXT:
continuation guidance. Cap now preserves the head (preamble/goal/progress)
and the NEXT: tail verbatim, trimming only the middle recap block.
- summary_builder: assistant turns that merely proposed or *asked* about work
were recorded under "Completed Actions", so a resumed agent could skip work
only discussed. Question-bearing assistant turns now flow to Pending
Questions / Remaining Work only; executed tool calls/results stay completed.
Adds regression tests for both.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
- Defect 1: map parsed markdown routing (TaskRoutingConfig) onto real
Task.branch_condition/next_steps the executor reads; stop clobbering
condition with a dataclass; guard dict-condition crash in step loop.
- Defect 2: wire Task(when=..., then_task=..., else_task=...) into the
WorkflowManager step-advance path for per-step conditional redirection.
- Defect 3: register `workflow validate` on the Typer workflow app so it
reaches the existing handler.
- Defect 4: align WorkflowStep schema with the runtime dialect (bare
agent/route/if/loop/repeat/parallel/include steps) and treat
backstory/instructions as aliases so shipped example workflows validate.
- Defect 5: fix .praison -> .praisonai list dir via shared path helper;
reject unsupported on_fail values clearly; fail loudly on YAML files in
the markdown workflows dir.
- Tests: executable markdown branch routing (matched + unmatched), when
routing, and a round-trip validation over examples/yaml/workflows/.
Co-authored-by: MervinPraison <[email protected]>
Wire --resume/--checkpoint/--rebase-checkpoint into `workflow run` and add a
`workflow checkpoints` subcommand. Store a stable, normalized definition
fingerprint in each checkpoint and compare it on resume, refusing to silently
land on a stale step index after the workflow file was edited (with
--rebase-checkpoint to force). Record resumed_from_step in the result.
Co-authored-by: MervinPraison <[email protected]>
Addresses two reviewer-flagged issues in the scheduler cross-run memory:
- P2 (nested aliasing): get_state/set_state now deepcopy so a caller
mutating a nested list/dict in a read result can no longer bypass the
16 KiB size cap or copy-on-read guarantee.
- P1 (orphan one-shot state): _persist_job_state now skips
delete_after_run jobs, which claim_due already removed (state popped)
before the run — persisting would recreate an orphan job_state entry
nothing cleans up.
Adds tests: deep copy-on-read/snapshot isolation (core) and one-shot
non-recreation (wrapper).
Co-authored-by: Mervin Praison <[email protected]>
Make the dormant verification primitive enforceable:
- Wire _verification_gate() into run_autonomous(_async): blocking hook
failures block a success outcome and feed captured stdout/stderr back as
feedback; never-passing checks end at max_iterations, not success.
- Fix lossy _run_verification_hooks re-wrap (preserve details/error/duration).
- Add declarative FileCheckHook (exists/non_empty/contains/json_field==equals),
no shell; CommandVerificationHook now accepts a string command.
- Feed the goal judge executable evidence; a failing blocking hook
short-circuits to continue without spending a judge call.
- add_verification()/remove_verification() for runtime registration.
- Add 'verification' run-journal event kind and YAML `verification:` config
parity mapping command:/file: shapes onto the two hook classes.
Opt-in only: agents without verification_hooks are unchanged.
Co-authored-by: MervinPraison <[email protected]>
Un-stub the documented structured_template compaction feature with a
deterministic DefaultSummaryBuilder that renders SUMMARY_TEMPLATE (falling
back to the plain summary on failure), wired as the default builder in
ContextCompactor when structured_template=True.
Add a deterministic session handoff assembler (build_handoff_prompt) that
turns durable state (recap + persisted GoalState + workflow checkpoint) into a
self-contained continuation prompt with no LLM call, plus a
`praisonai session handoff` CLI command with --json/--copy.
Co-authored-by: MervinPraison <[email protected]>
Address two P1 review findings on the standalone scheduled sender:
- _HttpSendError now preserves the raw Retry-After header verbatim (via a
headers dict) instead of pre-parsing to float, so server_retry_after honours
both integer-seconds and HTTP-date forms. A float(raw) previously discarded
any HTTP-date delay, letting the sender retry early and exhaust attempts under
throttling.
- WhatsApp standalone sender no longer maps a generic scheduler thread_id to
context.message_id. WhatsApp requires a real inbound message id to quote-reply;
a generic thread id is rejected as an invalid permanent 4xx. This mirrors the
live adapter, which only sets context from an explicit reply_to.
Adds tests for HTTP-date + integer Retry-After parsing and WhatsApp thread_id
context omission.
Co-authored-by: Mervin Praison <[email protected]>
Slots are typed Dict[str, Any] and SuggestionStore accepts nested
lists/dicts/sets; the previous tuple(sorted(items)) key raised TypeError
on unhashable slot values before recurrence could be recorded. Add a
recursive _freeze() helper producing a deterministic, hashable key.
Co-authored-by: Mervin Praison <[email protected]>
Implement the core JobStateStoreProtocol on the existing
ConfigYamlScheduleStore (get_state/set_state/clear_state), persisting a
small, size-bounded per-job scratchpad under a job_state key in
config.yaml and clearing it when a job is removed or a one-shot fires.
Wire the bot ScheduledAgentExecutor to load prior state into a compact
Notepad prompt block, thread it into a stateful monitor gate only when
the gate opts in (signature-detected), and persist GateResult.state_updates
plus any agent-emitted state back after the run. All hasattr/signature-gated
so stores and gates without support behave exactly as today.
Co-authored-by: MervinPraison <[email protected]>
Standalone (out-of-process) scheduled delivery was fire-and-forget with a
platform coverage gap. This adds:
- Bounded in-process retry with exponential backoff (honouring Retry-After)
around standalone HTTP sends, reusing the shared bots._resilience
classifiers so only transient 5xx/429/network failures are retried and
permanent failures (bad token, 4xx) fail fast. An ephemeral cron/serverless
tick has no persistent process to drain a durable SQLite outbox, so a
bounded retry is the lightweight durability fit for that model.
- Standalone senders for WhatsApp (Cloud API) and Signal (signal-cli bridge),
the two live-adapter platforms previously missing from _STANDALONE_SENDERS.
The documented `all` fan-out token is already implemented in botos.py
`_deliver_job_text`, so no change was needed there.
Co-authored-by: MervinPraison <[email protected]>
The consent-first automation-suggestion surface (store, TTL, dedup, chat
Accept/Dismiss buttons, CLI) already exists but nothing ever generated a
suggestion — propose() was only reachable via manual CLI calls, so the
"noticed you ask for a morning summary every day — automate it?" moment
never fired on its own.
Add a lightweight, deterministic recurrence generator as a new
SuggestionEngine.observe() method in the wrapper: a caller feeds each
completed turn's inferred blueprint+slots, and once the same intent recurs
threshold times within a trailing window the engine auto-calls propose().
Consent-first is preserved (jobs still only materialise on explicit accept),
per-principal isolation and store dedup keep it non-nagging.
Minimal in-repo fix — no new package, dependency, or export; reuses the
existing propose()/store machinery.
Co-authored-by: MervinPraison <[email protected]>
The default-on model turn pre-flight (#4042) now runs after the channel
probe in `gateway start`, so two pre-existing start-path tests that never
mocked a model round-trip aborted with exit 1 instead of their expected
exit 0. Mock `_verify_turn_preflight` green in both so each test isolates
exactly the behaviour it guards (SSL-only soft-fail; --no-preflight skip),
without weakening any assertion.
Co-authored-by: Mervin Praison <[email protected]>
The gateway passes the durable platform adapter directly to
ChannelSupervisor.run(), so drain_outbox() lives on the bot object itself,
not on bot.adapter. Check the object first, then fall back to bot.adapter,
so built-in channels actually re-drain on recovery. Adds a test for the
production object shape.
Co-authored-by: Mervin Praison <[email protected]>
Second-resolution timestamps caused two exports into the same directory
within one second to resolve to the same path, silently truncating the
first bundle (Greptile P1). Add _unique_bundle_path() to append a -N
suffix when the timestamped name already exists, preserving every export.
Co-authored-by: Mervin Praison <[email protected]>
Address Greptile P1 review feedback on #4045:
- Decouple turn verification from the channel --preflight flag so
`--no-preflight --verify-turn` still runs the model round-trip.
- Bound the preflight turn with a short run_timeout (default 60s,
overridable via gateway.preflight.verify_turn_timeout) so a stalled
provider fails fast instead of inheriting the 5-minute interactive
default at startup/onboarding.
- Pass per-agent base_url/api_key/api_base through to the synthetic
Agent so preflight exercises the same provider the live gateway uses.
Adds 3 tests covering timeout default/override and provider parity.
Co-authored-by: Mervin Praison <[email protected]>
Add a `praisonai gateway diagnostics export` command that writes a single,
pre-sanitised .zip (summary, health, config shape, redacted logs, forensics
snapshot) an operator can safely attach to a bug report. Reuses existing
secret redaction, forensics snapshot, health probe, and daemon log readers;
config is reduced to key/mode shape with no secret values, and all text is
passed through the shared redact_secrets pass. Runs even when the gateway is
unhealthy via local log fallback.
Co-authored-by: MervinPraison <[email protected]>
The durable outbox held deliverable replies through a transient channel
outage but nothing re-attempted them on recovery — they sat undelivered
until the next inbound chat() turn or a process restart. The supervisor
now fires a bounded, best-effort adapter.drain_outbox() when a channel
comes back after a recoverable outage, closing the loop opened by the
attempt-and-age policy. Duck-typed and no-op for bots without the hook.
Co-authored-by: MervinPraison <[email protected]>
Wire the existing run_turn_test into the default gateway start preflight and
the onboard wizard so a missing/invalid model credential fails at setup time,
not silently on the first user message. Default on via
gateway.preflight.verify_turn; opt out with --no-verify-turn or
PRAISONAI_NO_VERIFY_TURN=1 for constrained/offline environments.
Co-authored-by: MervinPraison <[email protected]>
- Re-validate every redirect hop + final URL in metadata fetch (SSRF-safe)
- Enforce HTTPS/loopback guard on discovered authorization/token/registration
endpoints so an HTTPS metadata doc cannot smuggle a plaintext endpoint
- Defense-in-depth HTTPS guard on the persisted refresh token endpoint
- Add tests for endpoint validation and redirect revalidation
Co-authored-by: Mervin Praison <[email protected]>
Fixes cross-level precedence bug flagged by greptile/reviewers: the
nearest project directory now wins over more distant ancestors, so an
ancestor .claude/.agents definition no longer silently overrides a
nested .praisonai one. Collect walk-up levels then apply ancestors
first / cwd last under the "later wins" order; .praisonai stays last
within each level.
Co-authored-by: Mervin Praison <[email protected]>
Extend CustomDefinitionsDiscovery to enumerate the conventional ecosystem
roots .claude/ and .agents/ for agents and commands, mirroring the skills
loader. .praisonai/ wins on name collision and the ancestor walk applies to
all roots. Tools stay .praisonai-only since loading executes user code.
Co-authored-by: MervinPraison <[email protected]>
Add metadata discovery, RFC 7591 dynamic client registration, and an
orchestrating provider that wires the existing PKCE callback helpers and
MCPAuthStorage into a lazy ensure_authenticated() flow. Fully additive and
backward-compatible: servers without auth or with pre-supplied credentials
behave exactly as before.
Co-authored-by: MervinPraison <[email protected]>
- redact.py: register PII BEFORE_LLM hook with sequential=True so
modified_input is actually applied to outgoing messages
- hooks/runner.py: warn when a parallel hook returns modified_input that
would be silently discarded
- agents.py: restore original self.tasks after planning; keep plan-step
tasks under _plan_tasks and reset task_id_counter to avoid id collisions
- context/composer.py: iterate _ensure_tool_pairs to a fixed point so
reinserted tool_calls messages don't leave dangling sibling calls
Co-authored-by: Mervin Praison <[email protected]>
- process.py: when a task uses only when/then_task/else_task routing and it
resolves to no target (taken branch has no target) with no next_tasks, end the
workflow instead of falling into _find_next_not_started_task() (sync + async).
- loader.py: include guardrails/retry/skills in _defaults_has_any_values() so a
config with only [defaults].guardrails (or retry) is applied instead of being
silently skipped by apply_config_defaults' fast path.
- factories.py: document ChromaMemoryAdapter's shared short/long-term collection
so per-tier reset semantics are explicit.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
- Process now consults Task.get_next_task (when/then_task/else_task) in both
sync and async workflows before falling back to next_tasks, and no longer
ends the workflow early when only when-routing is configured.
- Knowledge.reset/delete_all/history now guard with hasattr, logging a no-op
warning for reset and raising NotImplementedError for unsupported erasure.
- ChromaMemoryAdapter (default) implements reset_short_term/reset_long_term so
the default memory config actually erases; Memory warns when an adapter lacks
reset support instead of silently succeeding.
- config defaults: guardrails and retry are now loaded into DefaultsConfig and
guardrails is applied to Agent via apply_config_defaults; unused/unconsumed
defaults keys (api_key/context/hooks/templates) removed from VALID_DEFAULTS_KEYS
so misconfiguration fails loudly, and retry added since agent.py consumes it.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: MervinPraison <[email protected]>
Agent.__init__ ran a dependency probe that eagerly imported the heavy
memory.memory module for any truthy memory value. The common FileMemory
paths (memory=True, "file", and file-provider dicts without learn) never
touch that module, so the probe wasted a ~100ms+ import. Scope the probe
to run only for values that resolve to the heavy Memory backend, preserving
the existing ImportError remediation for every heavy branch.
Co-authored-by: Mervin Praison <[email protected]>
Document Evolink's OpenAI-compatible endpoint in the sample environment and add a provider example that uses the existing PraisonAI base_url/api_key configuration path. Cover the config wiring with an offline unit test so the integration stays regression-tested without live API calls.
2026-06-23 19:12:11 +08:00
1113 changed files with 156570 additions and 19126 deletions
<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>
<figureclass="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 <cmd></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>
<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 -->
<h2class="wp-block-heading">Bridges (no PyPI cycles)</h2>
<p>Wrapper pins <code>praisonai-mcp>=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>
<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>
<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>
<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>
<figureclass="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>
<figureclass="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 -->
<h2class="wp-block-heading">When to use which tier</h2>
<!-- /wp:heading -->
<!-- wp:table -->
<figureclass="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>
@@ -308,7 +319,7 @@ function finalClaudeCompletedOnSha(comments, headPushedAt) {
}
constFINAL_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 praisonai-rust or praisonai-ts. 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: 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';
body: '🤖 Auto-triaging with Claude. Analyzing issue and implementing a fix...\n\nClaude will:\n1. Route correctly: 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'
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'
if (await mergeGate.shouldSkipMergeGateDispatch(github, owner, repo, prNumber, core)) {
return;
}
await github.rest.repos.createDispatchEvent({
owner,
repo,
@@ -288,7 +293,7 @@ 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.
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).
'**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 ---
Read AGENTS.md to understand the architecture rules.
SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify or touch praisonai-rust or praisonai-ts packages.
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.
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 → 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.
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.
- 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"
@@ -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)
- 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 |
| 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 |
@@ -130,7 +130,7 @@ Each layer wraps the one inside it. When an agent misbehaves, the layer tells yo
| **3 · Harness** | Can it act, and be checked? | `tools=`, `MCP()`, `guardrails=`, `approval=`, `hooks=`, `sandbox=` |
| **4 · Loop** | When do we stop? | `execution=ExecutionConfig(...)`, `reflection=`, `autonomy=`, doom-loop detection |
| **5 · Graph** | Who runs when, and who checks whom? | `AgentFlow`, `route()`, `parallel()`, `loop()`, `repeat()` |
| **⬡ Managed** | *Where does it actually run?* | `run_on="docker"` — one shared remote sandbox, or a fully hosted loop |
| **⬡ Managed** | *Where does it actually run?* | `tools_run_on="docker"` — one shared sandbox for the tools, or `run_on="anthropic"` for the whole agent |
### Layer 1 · Prompt — *Did I say it clearly?*
@@ -235,7 +235,7 @@ The harness is commoditising; **where** the agent executes is the next multiplie
pip install praisonai
```
The simplest way in is `run_on=` — one whole team or workflow shares **one** sandbox, so a file written by step 1 is there for step 2:
The simplest way in is `tools_run_on=` — one whole team or workflow shares **one** sandbox, so a file written by step 1 is there for step 2. Thinking stays on your machine:
```python
frompraisonaiagentsimportAgent,AgentFlow
@@ -243,7 +243,7 @@ from praisonaiagents import Agent, AgentFlow
Report issues against the **specific package** where the vulnerability exists. A platform-only issue does not necessarily affect the core SDK, and vice versa.
Examples demonstrating the MCP Server v2 features per MCP Protocol Version 2025-11-25.
## Features Demonstrated
@@ -130,3 +157,19 @@ These examples comply with MCP Protocol Version 2025-11-25:
-`destructiveHint`: true
-`idempotentHint`: false
-`openWorldHint`: true
## Build Remote Agent (`gbr_pair.py`)
Pair a phone running [Build Remote Agent](https://grokbuildremote.com/) to a PraisonAI agent via MCP stdio `gbr-mcp` (protocol `gbr/1`). Requires `gbr-agent pair && gbr-agent run`. Phone is spectator. Never put mailbox keys in the example.
```bash
python gbr_pair.py
```
## What the phone sees
**Terminal windows** on this PC (machine-wide mailbox). Not headless OpenCode / CodeNomad sidecar / Electron. `:8788` in a sidecar is Bot API JSON, not a transcript.
Platform releases are **independent** of `praisonai` / `praisonaiagents` version numbers. A platform-only fix (e.g. `0.1.8`) does **not** require bumping the main SDK.
13.**praisonai-platform**advisories are handled in a different repository:
| `praisonai-platform` | Independent semver, **separate repository** | Not released from this repo — see [PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform) |
Always set the advisory `patched_versions` to the **package that actually contains the fix**. Do not imply users must upgrade all packages when only one changed.
@@ -217,9 +215,11 @@ Close (or publish advisory with no code change only if already mitigated) when:
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).
@@ -1074,7 +1088,7 @@ PraisonAI's reliability engineering rests on three pillars — **C**ontext, **H*
> **Evaluator status.** The `ContextEvaluator` / `HarnessEvaluator` / `LoopEvaluator` classes named in the rubric are **planned follow-ups** (PA-CHL-001–004), not yet in the repo. Until they land, the equivalent checks are covered by existing modules:
>
> - **Context** — `context/tokens.py` and `eval/tokens.py` (`estimate_tokens` / `count_tokens`) for budget; `compaction/compactor.py` (`ContextCompactor`) for compaction/retention.
> - **Harness** — `runtime/turn_context.py` for turn-context parity; `runtime/example_harness.py` and `tests/` for harness/artifact checks.
> - **Harness** — `runtime/turn_context.py` for turn-context parity; `examples/runtime/example_harness.py` and `tests/` for harness/artifact checks.
> - **Loop** — `eval/loop.py` (`EvaluationLoop`) and `agent/autonomy.py` for convergence and doom-loop guards.
>
> Keep terminology consistent with the `agent/context_agent.py` (`ContextAgent`) PRP methodology.
# Only schemes backed by a registered memory adapter belong here. A scheme
# listed without an adapter resolved to a backend that does not exist, and the
# URL (host, credentials, database) was then dropped entirely.
MEMORY_URL_SCHEMES:Dict[str,str]={
"postgresql":"postgres",
"postgres":"postgres",
"redis":"redis",
"rediss":"redis",# Redis with SSL
"sqlite":"sqlite",
"mongodb":"mongodb",
"mongodb+srv":"mongodb",
Loaded 100 of 1113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.