Skip to content

Desktop agent: merge main (v0.6.1) into atagdesktop - #451

Open
plombeer31 wants to merge 99 commits into
atagdesktopfrom
desktop/sync-main-0.6.1
Open

plombeer31 wants to merge 99 commits into
atagdesktopfrom
desktop/sync-main-0.6.1

Conversation

@plombeer31

@plombeer31 plombeer31 commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Brings the desktop's agent up to v0.6.1: config format 65, Fusion, the sub-call partition fix (#411#421), and everything else merged to main since atagdesktop branched. desktop/ is untouched by the merge. The desktop-only agent routes (/api/coding-mode, /api/context-preview) come through intact.

Why

The desktop build ships the agent from this branch inside the app, and that agent was still on config format 62. The live desktop state is already on 64, so the bundled agent couldn't read it. Separately, the Fusion settings (#449) need an agent ≥ 0.6.0.

The one real conflict: which failure a failed turn reports

  • main throws the chain's last link's error untouched, because classification, fallover and the outage wait all decide on it. It records the links that failed before it beside the error (attachFailedAttempts).
  • atagdesktop threw the primary's error instead. Otherwise, on the usual chain [cloud provider, auto-appended llama-server], the desktop showed "not answering" for a cloud provider that had actually answered with a 402.

Resolution: take main's runtime contract as it is, and report the first recorded link where the desktop reads it, the HTTP stream:

  • loop_failed over SSE now carries the provider the operator picked: its own message, its own category (classifyFailure), and fallback_failures (every failed link) on the extensions frame.
  • The OpenAI-compatible frame carries the same message and no extra field.
  • A single-link failure is reported byte for byte as before.
  • The non-stream path is unchanged.

The desktop's failure line reads error + category from that frame, so it keeps naming the chosen provider's refusal.

Checks

  • tsc --noEmit clean.
  • vitest: src/llm/fallback, new src/http/stream-failure-frame.test.ts, provider-waiting-stream, openai-chat-completions, route-coding-mode, route-context-preview — 14 files, 120 tests pass. They run under Node 22; this checkout's better-sqlite3 is built for ABI 127.
  • SEA bundle built on Node 25.7 (buildbundle:seabuild-binarypackage): atomic-agent 0.6.1, and task list opens sqlite on a throwaway state.
  • Test DMG: this agent inside facelift/integration (desktop: Soft Tactile redesign — every screen, integrated #450), built with npm run dist (ad-hoc signed as ai.atomicbot.desktop). I took the app out of the DMG and drove it with trusted CDP input against a copy of a live desktop state on format 64, 7/7:
    • it connects in 2 s on the agent bundled inside the app
    • Soft Tactile + Figtree load, main window, no wizard
    • the mode chip reads the stance, and default → plan round-trips through /api/coding-mode
    • a Settings → Privacy write lands with no version wall
    • the config migrated 64 → 65 on boot

Update: fixes from testing the facelift DMG

Valerii tested the DMG. This branch now also carries the agent side of round 2 (desktop PR: facelift round 2).

  • fusion_worker over SSE (39b01c13). The worker progress event is forwarded as an extensions-only named frame, so the desktop can show live Fusion workers.
  • Stored approvals (8c9a1eb2). Each answered approval (verdict, category, time) is recorded on the tool result it belongs to, so a reopened chat shows it under its call. Auto-approved actions record nothing.
  • Vision (325714ca). A model the bundled catalogue marks text-only is no longer assumed vision-capable, so it gets "does not accept images" immediately instead of three rejected uploads. That was the repeated vision.describe 400 on deepseek-v4-flash. An explicit config setting still wins. Provider error text is kept up to 1,200 characters.
  • Shell results (027d4958). 21 of 29 shell results in the tester's turns were cut at 399 characters, right after the echoed command, so the model never saw what its checks printed. The result now names the command by its first line and keeps the end of the output, and a Python traceback is labelled by its exception line. The budget rises from 400 to 2,000 characters / 40 lines — a default change.
  • Replies that never stream (91378043). The stream hook skipped every assistant_reply for the rest of the turn once any delta had streamed. The max-steps stop message, the loop breaker's answer and non-streamed retries were therefore dropped live, and the turn looked cut off. The skip is now per step: no repeat of text already streamed, only the missing tail after a partial stream, and a never-streamed reply in full after a blank line.

Checks

  • tsc is clean.
  • vitest: 91 files, 926 tests pass across src/http (stream, failure, Fusion-worker and reply-frame suites, chat completions), src/llm/fallback, src/tools/os and src/tools/vision.
  • SEA bundle rebuilt on Node 25.7: atomic-agent 0.6.1, and task list opens sqlite.
  • Driven end to end in the desktop: turn-order.drive --receipts 8/8, fusion.drive 17/17. The packaged app from the DMG passes 9/9 on a copy of a live desktop state.
  • Known unrelated failure: src/sidecar/send-message-concurrency.test.ts fails in the full suite with or without these changes.

plombeer31 and others added 30 commits September 10, 2026 16:52
… a session swap

The rail's blue fill stops being drawn after a new session, and the rows
it used to own keep that fill under the composer. Reproduced over a PTY
at 120x34, rendering the escape stream into a pyte grid and counting the
cells carrying a non-default background, before and after ctrl+g n:

  painted rows          before the swap   after the swap
  incremental: true          33                 1
  incremental: false         33                33

Ink's incremental writer skips any line whose rendered string matches the
last frame's and anchors that diff by counting rows up from the bottom of
the previous block. A session swap replaces the transcript, the rail and
the meta bar in one commit; the anchor does not survive it, and the lines
the renderer believed were already correct are no longer where it left
them.

instance.clear() on the session change was tried first, so the frame
after the swap would be written against an empty cache. The callback was
instrumented to prove it fires; the screen is still wrong. The desync is
not something the app can resync from above, and the option is Ink's own
experimental one, so it goes off here.

The elapsed-time tick stays at 1000 ms — that part of #379 was three of
every four wake-ups producing the identical string, and it is the larger
share of the repaints a running turn asked for. What comes back is the
blink on terminals without synchronized output, which is what v0.5.6 and
everything before it did.

Both assertions move with the option: the unit test on
buildInkRenderOptions and the end-to-end one that reads the options off
the intercepted render() call.
…work

Fusion's split has been advice since the mode shipped, and a capable
cloud model handed a catalog of forty tools reads the twelve files
itself and never fans anything out. A QA run showed exactly that: nine
steps, twelve os.fs.read calls, zero delegations, every completion on
the cloud model.

So the first mutation of a fusion turn is now refused until the turn has
delegated once. Read, plan, split, delegate — then the gate opens, because
two things genuinely belong to the orchestrator afterwards: integration,
which is a write, and anything a worker handed up because it needed
approval, which workers cannot request.

A refusal rather than a hidden descriptor: removing tools mid-turn
rewrites the stable prefix and drops the session's KV cache, while a
refusal costs one tool result and reads as an instruction. Same trade
plan mode makes, and the same one fusion.delegate already makes for a
worker that calls it. Read-only tools, the fan-out itself and the
terminal verbs are never gated — planning is reading, and vetoing reply
would veto the turn's exit.

The guidance is rewritten around the loop it now enforces: plan in the
open with the parts sized, one task per part in one call, briefs that
stand alone, read every reply against its brief, merge yourself, and send
rework back out instead of quietly absorbing it. Same prefix budget as
before (1344 of 1400 bytes).

A worker's own turn is never gated: the flag is per turn and skips
ephemeral ones, so the hands the mode exists to free stay free.
A first live run on a cheap auto-routed model read the files, replied
'no edits were needed', and changed nothing. Stopping after the plan is
the cheapest way to satisfy 'plan in the open', so the line now says the
delegation belongs to the same turn.
The loop built the per-turn fusion context and handed it to the step
executor, which builds the batch context and forwards `isPlanMode` — and
nothing else. The gate was live, unit-tested and never consulted: a
driven run in fusion mode wrote all four files itself with zero refusals
and zero workers.

Forwarded the three fields the same way `isPlanMode` travels, together
so a context can never carry the flag without the ledger behind it.
Remote operators could read and steer a session from a chat but never
change what it ran on, so a bot on someone else's machine was pinned to
whatever model that machine was last told to use.

/model reports the active provider and every configured one; /model
<provider> [model-id] switches. It writes the same state the TUI's LLM
pane owns — the active text provider and its defaultChatModel in the
user config, plus the per-session llm stamp — so there is no second,
channel-local store to drift.
Reported on Discord: mercury-2.5 misforms tool calls without OpenAI's
strict function schemas, and there was no way to ask for them.
"strict" was already a legal supportsTools level, parsed out of config
and carried through the model resolver, but nothing consumed it —
setting it did nothing at all, and extraBody merges at the top level of
the request body so it cannot reach a per-tool function.strict either.

Strict schemas are far more restrictive than the descriptors this repo
carries, so the conversion is per tool and refuses per tool: a mixed
tools array is legal, while a whole-array flag would 400 every request
the moment one tool does not fit.
The per-session NDJSON cap was enforced from the wrong end. On reaching
`tracing.trace.maxBytesPerSession` (10 MB by default) the sink appended
one `trace_truncated` marker, set `overflown`, and dropped every later
event for that session — permanently, and across restarts, because
`resolveState` re-derived `overflown` from the file size. A long session
therefore kept a pristine record of its opening minutes and nothing at
all about its end, which is where "what went wrong" lives.

The cap is now honoured by dropping the OLDEST events instead of
refusing new ones. When an append would cross it the sink rewrites the
file keeping its tail: whole leading lines go until the file is at half
the cap, a `trace_truncated` marker is left at the seam carrying
`droppedEvents` / `droppedBytes`, and writing continues. A leading
`session_started` row is preserved so `trace list` and `trace replay`
still find the header. There is still exactly one file per session, so
`trace show/export`, the debug bundle, the issue report and the eval
harness need no changes.

The rewrite goes through a temp file in the same directory and an
atomic rename, so the trace is never missing or half-written; a trim
that cannot be completed degrades to the old stop-writing behaviour and
never throws into the caller. Halving on each trim bounds the cost to
an amortised two bytes rewritten per byte traced, and a file already at
the target is never rewritten — a single event bigger than the cap is
dropped on its own instead of triggering a rewrite per event.
… fan-out

Nobody should be picking the worker count from a list. It is two
decisions and neither belongs to the operator: how many workers this
machine can serve at once, which the machine knows, and how many a given
job is worth splitting into, which the orchestrator knows once it has
read the job.

The machine half: `localModels.managed.parallel` takes `"auto"` and
defaults to it (config v63). llama.cpp divides `--ctx-size` between
`--parallel` slots, and a slot smaller than a worker's own prompt cannot
serve one — so the count is how many times MIN_SLOT_CONTEXT (16k, the
same figure as MIN_AUTO_CONTEXT) fits in the context the daemon is
launched with, capped at 8, and 1 on a CPU-only launch where concurrent
slots share the same cores. That context is already sized from VRAM by
context-size.ts, so a bigger machine widens the pool with no new probe
and nothing to configure. Resolved in daemon-lifecycle, where the
effective context is known; both launch sites pass the config value
through untouched.

Migration: a pre-v63 file's `parallel: 2` is the schema's old default,
not a choice anyone made, so it reads as `"auto"`. Any other number is
someone's deliberate pin and is kept — as is a 2 written at v63 or later.

The model half: a `fusion.delegate` call that names no `maxWorkers` now
gets the machine's capacity instead of `llm.runMode.fusion.workers`. The
pool notice was reworded to match — it names the slot count as where the
number comes from rather than as a knob to go and raise.

The composer's count rows are gone; its label reads "up to N workers",
because N is the ceiling and not a promise about this turn. The worker
MODEL rows stay: which model runs the workers is a real choice.
`/runmode workers N` survives as the pin.
`loadImageFile` derived an image's MIME type from `extname()` alone, so
whatever the uploader called the file decided what we told the provider
it was. Every attachment that reaches the agent through a chat channel
carries a client-chosen name: `attachmentBasename` in the channel inbox
returns a sanitised filename unchanged whenever it already has an
extension, and never looks at the content. A PNG screenshot sent from
Telegram lands on disk as `photo.jpg`, `describeImageViaOpenAi` builds
`data:image/jpeg;base64,<PNG bytes>`, and the provider answers with an
opaque 400 that names neither the file nor the mismatch.

Sniff the magic number instead: a small pure `sniffImageType` module
(PNG, JPEG, GIF87a/89a, WebP) reading at most a 12-byte prefix, with the
extension kept as the fallback. Bytes win when they identify a supported
format; unrecognised bytes are "no opinion", not "not an image", so
anything that describes fine today keeps describing fine. A supported
image with no extension at all now loads instead of being rejected
unread, and `UnsupportedImageFormatError` says both signals failed
rather than blaming the extension. A disagreement is logged at warn on
the load seam so the mismatch is visible without a repro.

The channel inbox is deliberately untouched — one seam per change; the
sniffer is standalone so it can be reused there later.
TelegramLockfile.release() unlinked the lock file whenever it existed,
with no check that the PID inside belonged to this process. On the
lock-conflict path that erases the wrong file: TelegramChannel.start()
releases from its catch block, so the process that LOSES the acquire()
race deletes the WINNER's lock on its way down. The winner keeps
polling from memory, the file is gone, and the next process acquires
"successfully" — two pollers on one bot token, stopped only by
Telegram's own 409 Conflict, which is precisely what the lockfile
exists to prevent.

DiscordLockfile.release() has had the ownership guard all along; this
brings the Telegram side in line. acquire()'s stale-lock reclaim, the
error message, and the Discord implementation are untouched.
An inbound Telegram or Discord message reached `runtime.runTurn` as
bare text. The model saw "restart the deploy" with nothing about who
said it or in which channel. That was survivable while a channel had
exactly one owner; Discord's `ownerUserIds` list made it a real gap —
several people now drive one bot and the agent cannot tell them apart.

Add a single-line `[from]` block, built the way
`buildAttachmentUserMessage` already builds `[attachments]`: display
name, platform, user id, chat id, and the topic id where the surface
has one.

Inclusion rule (deterministic, tested): always on Discord, which is
multi-author by nature; on Telegram only in a group or supergroup. A
Telegram DM reaches the runtime for the single configured
`ownerUserId` only, so the line would restate a constant on every turn
forever.

Ordering: the identity line goes above the user text and above any
`[attachments]` block — envelope first, attacker-controlled payload
after, so there is exactly one `[from]` line and it is the first line
of the turn.

The display name is attacker-chosen text, so it is flattened to one
line (control characters, newlines, bidi format characters and the
Unicode line/paragraph separators all become spaces), emitted inside a
quoted field with `"` and `\` escaped, and capped at 64 characters
before escaping. A nickname therefore cannot start a line at all and
cannot forge a second `[from]` line or an `[attachments]` block. Ids
get a strict character allowlist — they arrive through a structural
cast, not a validated one.

Nothing outside the live channel message path changes: the TUI, tasks,
webhooks and fusion workers are untouched, and so is the Telegram file
path, which is private-chat-only.
Some models call tools reliably only when the provider constrains
decoding to the tool's schema — OpenAI's strict mode. Until now there
was no way to ask for it: `strict` is a field on each tool
(`tools[].function.strict`), and `tools` sits in `RESERVED_BODY_KEYS`
and is re-applied on top of the `extraBody` merge, so the vendor
passthrough could never reach it. The dormant `supportsTools: "strict"`
catalog level did not help either — nothing has ever read it.

Adds `strictTools` to the provider entry (config v63, off by default,
byte-identical request body when absent) and a schema transform that
rewrites each tool into the subset strict mode accepts: objects closed,
every property listed in `required` with optional ones widened to
nullable, and the value-range keywords the runtime validators already
enforce stripped. Tools whose arguments are a free-form map cannot be
expressed strictly and are sent unconstrained rather than silently
losing their payload.

Nullable optionals change the response too — a strict model sends
`"userName": null` where it used to omit the key, and several tools
branch on presence rather than value. So on a provider that opted in,
the tool-call adapter is wrapped to drop top-level null arguments,
leaving the parsed call identical to what those tools saw before.

A conformance test walks every descriptor the agent registers through
the adapter and the transform and checks every strict rule recursively,
so a new tool with an unsupported keyword fails here rather than as a
400 that kills the whole request.

Reported on Discord by thegreatteacher (2026-09-10) against Inception
Labs' Mercury 2.5.
…ters

The word guard alone missed the spaced form of the same arithmetic:
G_cont = G1 * G2 * G3, omega = 2 * pi * 5, Octave's A .* B .* C and
SELECT * FROM t still lost their asterisks. A * followed by whitespace
cannot open emphasis under CommonMark either, so require the body to
start and end with a non-space character.

Switch both rules from \w to Unicode letter/number classes. \w is
ASCII-only in JS, so the guards did not apply to non-Latin prose:
пи*2*пи was emphasised where pi*2*pi was not, and слово_это_слово lost
its underscores where snake_case_name kept them.

Refuse a candidate whose body does not close the tags it opens, so an
<i> can never cross a <b>/<s>/<a> emitted by an earlier inline pass.
Telegram answers crossing tags with a 400 on the whole sendMessage.
Mutation testing against the new suite: `release()` could swallow the
read error and unlink anyway, or unlink whenever the pid failed to
parse, and every test still passed. Both are the original bug wearing a
different hat -- on POSIX, unlink permission comes from the directory,
so an unreadable lock held by another user's atomic-agent would be swept
away and its token handed to a second poller.

- the not-throwing cases now also assert the file is still there;
  "did not throw" alone passes for a release() that deletes all of them
- an unreadable (0o000) lock file must survive release(), skipped where
  file modes do not bite (root, Windows)
- a read-only state dir pins the other half of the contract: the unlink
  can fail after a successful read and release() still says nothing
- a fractional pid joins the junk-contents table

All six mutations now fail at least one test (revert, drop the guard,
flip === to !==, swallow the read error, unlink on NaN, rethrow from the
catch). Telegram suite 243 passed.

Also corrects the new class docstring: a file we leave behind is only
reclaimed when its PID is dead. A PID the OS recycled onto an unrelated
process keeps acquire() refusing until someone deletes the file, which
is the trade this guard makes and DiscordLockfile already made.
Review fixups on the `/model` command.

The mid-turn refusal only covered the issuing chat's own session, but
`llm.activeTextProvider` is global and `resolveActiveLlmSlice` re-reads
it per inference attempt — provider, tool transport and tool-call
adapter with it. A `/model` from one chat therefore landed on every
other in-flight turn at its *next step*, not its next turn, which is
exactly the hazard the refusal existed to prevent. It now gates on
`turnController.busySessionIds()` and names how many sessions are
running so the operator can wait or `/cancel`.

Also:

- A failed provider reload no longer leaves a torn config. The model pin
  has to be written before the rebuild (the rebuild reads it off disk),
  so the pin — and the in-memory active provider — are rolled back
  before answering "Could not switch"; a later bare `/model` can no
  longer report a model that was refused.
- `stampChatSession` moved inside a `catch`. It ran after the try block,
  so a `sessionStore.save` failure rejected the whole command *after*
  the config write had succeeded: no reply at all on Telegram, an
  unhandled rejection on Discord's `void this.onDispatch(...)`.
- `/model` registered in Telegram's `setMyCommands`, so it appears in
  the command menu and autocomplete like every other verb.
- A one-token argument starting with a slash (`/model /vendor/m-9`) is
  answered with the command's shape instead of "Unknown provider .".
- Trailing arguments are refused rather than silently dropped.
- Tests: the API-key environment variables are saved, cleared and
  restored per test, so a developer or CI with a real OPENROUTER_API_KEY
  exported no longer fails (or vacuously passes) the "no API key" cases.
- Tests: the `openai-compat` fixture gains `baseUrl`/`defaultChatModel`,
  which the real registry requires, and a new test builds the fixture
  through `ProviderRegistry.fromConfig` so the one stubbed seam
  (`reloadLlmProviders`) can no longer hide a config production refuses.
The keyword allowlist bounded what a node may say; nothing bounded how
many of them there were. The built-in descriptors hid it — the deepest,
`os.shell.run`, nests two levels — but a third-party MCP `inputSchema`
is not so polite, and both failure modes are the one this module exists
to prevent:

  * a schema nested past a strict compiler's ceiling is rejected with
    the WHOLE request, taking every other tool's definition down with
    it, which is exactly the outcome the per-tool refusal buys its way
    out of;
  * a self-referential schema ran the recursion into a `RangeError`
    that escaped `descriptorsToOpenAiTools` and killed the step. It
    cannot come off the wire, since MCP schemas arrive through
    `JSON.parse`, but nothing here promised that.

Five levels is the conservative reading of the published ceiling, and
refusing deeper costs that one tool its strict marking and nothing else
— it ships exactly as it does today. Built-in coverage is unchanged at
77 of 82.

Also pins two refusals that no test held. `anyOf` beside a sibling
`type` was already declined and reads as a contradiction to a strict
compiler, so passing it through would emit a node no provider can
compile; inverting that check used to be a silent mutation. And AGENTS.md
now records that the null-drop is per request rather than per tool, so a
refused tool cannot express a literal top-level `null` while the level is
set.
…tes the read

Typing an image from its bytes means `loadImageFile` opens every path
the agent names. The extension check used to be what stopped it: a
`.log` or an extension-less device path was rejected without ever being
opened. With that gone, `readFile` runs first and the two unbounded
cases became reachable from `vision.describe`:

- `/dev/zero` (or any character device / FIFO): measured on this branch,
  `readFile` climbs past 2.8 GB RSS in 15 s and keeps going. On main the
  same path was rejected in 11 ms.
- a large regular file: a 1.5 GB `install.log` allocated the full 1.5 GB
  in 3.1 s only to be rejected afterwards by the same
  `UnsupportedImageFormatError` main raised in under a millisecond.

Front the read with a `stat`: reject anything that is not a regular
file, and reject a file whose on-disk size already exceeds the caller's
cap. `vision.describe` passes `config.vision.maxImageBytes` down as that
cap, so an over-size image is refused from its size instead of being
materialised and then thrown away — which also closes the pre-existing
gap where the cap was only enforced after the whole file was in memory.
The post-read byte-length check stays: it is the only thing that covers
a file growing between the stat and the read.

Both rejections carry their own error type so the tool surfaces the
message as-is rather than wrapping it in `failed to load image: …`.
Two things checked against the code rather than against the prose.

`SwarmRegistry.startEnabled()` filters on `enabled && token !== null`,
so an enabled swarm unit with no token is silently skipped. "every
enabled swarm bot" promised more than serve delivers; it now says "that
has a token", in the README and in `serve --help`.

`atomic-agent --help` still described serve as an HTTP API only. That
top-level line is the one people read before they ever run `serve
--help`, so leaving it stale keeps the discovery gap this change set out
to close. Reworded to name the channels.

Also reflows the help paragraph so no line trails a two-word orphan.
Review fixups on top of the empty-completion recovery.

A recovery that "spends a step" is a promise of another inference. On
the last step of a leg that has produced nothing usable that promise
was false: the recovery burnt the step, the `no_progress` boundary
broke out of the loop before `executeStep` ran again, and the operator
read "trying again (1/1)" for a try that never happened — then got
"ran out of steps" instead of the model's own diagnosis, with the
ModelError dropped and no `loop_failed`, so the failure never reached
error reporting either. `recoveryStepAvailable` gates both recoveries
on a step actually being left; the parse path had the identical hazard
and gets the same guard.

The empty-completion budget is now per RUN of consecutive empties
rather than per turn: any completion that carried something — a step
that ran, a body that failed to parse, a reply the server cut short —
resets the count. A dead link still buys exactly one retry for the
whole turn (nothing resets it), while a model that answered a step and
then went quiet gets its own nudge instead of being denied one on the
strength of an empty completion a dozen working tool calls ago. It is
also what makes the terminal message's "twice in a row" true, which it
was not before.

Tests for the three rendering branches the previous commit shipped
uncovered: the TUI reducer line, the trace recorder payload and the
`trace show` formatter row. Each was mutation-checked (line replaced,
payload fields set to -999, formatter case replaced) and each mutation
fails the new tests.

Docs corrected where they overclaimed: the rewritten message does NOT
make the doubled empty distinguishable in Sentry — the scrubber never
transmits a message and `pickFrames` keeps the cause's stack, which is
deliberate (same defect, same cluster). The trace is where the two are
told apart.
Review follow-ups on the `[from]` identity line.

- `sanitizeDisplayName` truncated with `String.slice`, which counts
  UTF-16 units: a 64-unit boundary landing inside a surrogate pair left
  a lone surrogate in the prompt. That is not valid UTF-8, so it turns
  into U+FFFD the first time the turn is encoded for a provider or
  written to the session store. Cut on code points instead, which also
  makes the cap mean 64 characters for an astral name rather than 32.
- The cap/escape ordering had no test that could tell the two orders
  apart — the existing one asserted `line.length < 200`, which holds
  either way. Pin the exact output so reversing the order fails.
- Three comments claimed more than the code delivers: that the cap
  bounds the rendered field (escaping doubles it), that a malformed id
  is dropped whole (its bad characters are stripped and the rest is
  spliced), and that there is exactly one `[from]` line in a message
  (only the name is escaped — a message body or a failed attachment's
  filename still renders a line-anchored `[from]` below the envelope,
  from any account on the owner allowlist). State the real boundary,
  and pin the last one with a test so it is a documented contract
  rather than a surprise.
- Cover C1 NEL (U+0085) and the C0 file/group/record separators in the
  injection table.

No behaviour change beyond the surrogate fix.
Review follow-ups on the head-trimming sink.

The cost bound the trim relies on was not enforced. `bytesWritten <=
target` skips a rewrite only when the file can reach the target, and a
trim cannot always get there: a preserved `session_started` plus the
marker is irreducible. When that floor sits above the target the guard
never fires and every single event pays a whole-file read + write +
rename. Measured on a 1.4 KB header at a 2 KB cap: 54 rewrites over 59
events; at a 256-byte cap, 40 over 40. Two changes fix it — the header
is only preserved while it is at most half the target (it exists to
introduce a tail, not to crowd it out), and the size a trim actually
produced is remembered so a file already at its floor is left alone.
Same measurements after: 8 over 59, and 2 over 40 — with more of the
session on disk, not less.

Also:

- flush the temp file before renaming it. `rename` is atomic for the
  directory entry, not for the data behind it, so a power cut after the
  rename could leave the trace pointing at an unwritten extent — losing
  the whole file rather than half of it.
- sweep temp files a crashed trim stranded. A process killed between
  the write and the rename leaks up to half a cap of unredacted trace
  content under a name no reader lists, one per crash, forever. Only
  temps whose owning pid is gone are removed.
- count a file's unterminated last line as a dropped event, so
  `droppedEvents` still adds up on a trace another process left
  mid-append.

Tests. Every cap test ran at 600 bytes, where the target (300) is below
`MARKER_BUDGET_BYTES` — the trim degenerates to "wipe everything but the
header" and no surviving tail is ever cut, so the newline rounding was
never executed. Cutting mid-line passed the whole suite. Added coverage
at caps where a real tail survives (asserting each surviving row is
byte-identical to the event that was emitted), for the rewrite bounds
above, for the temp sweep, and for the marker's seq and the
unterminated-line count.

The `trace_truncated` marker no longer means "the trace stops here", so
the eval postmortem's rendered line and two stale comments that still
described the old cap behaviour are corrected.
…ections

Review fixes on the strict tool schemas.

The null drop was gated on the batch-level flag while the conversion is
per tool, so a tool whose schema was REFUSED — shipped byte-identical to
the flag-off payload — also had its top-level nulls deleted. For a
third-party MCP tool with a required ["string","null"] argument the model
sends null because the tool's own schema asked for it, and the server
received a call missing a required key. The adapter now reports the
escaped names it actually marked strict (strictToolNames) and the batch
side undoes the padding for exactly those, so "the refusals ship exactly
as they do with the flag off" is true on the way in as well as out.

The converter also refused the two standard spellings of "nullable", so
it was not idempotent and rejected nearly every pydantic/FastMCP schema:
type: ["string","null"], an anyOf with a {type:"null"} branch and a bare
{type:"null"} are now accepted and never widened twice, and the
annotations default/$schema/$comment are accepted and dropped (a strict
decode has no absent key for a default to fill; the null is deleted again
on the way in, so the server applies its own default as it does today).
$defs/$ref stays refused.

An absent additionalProperties was treated as false, inverting the JSON
Schema default: an MCP object the server left open was closed and still
marked strict, and a zero-property MCP tool was published as a
zero-argument tool. It is now refused unless the schema says false
outright, which costs the built-ins nothing — default-tool-args-schemas
spells it out on every object, and coverage is unchanged at 77 of 82
registered schemas, 76 of 82 emitted functions.

A property named __proto__ was silently dropped (out[name] = ... on an
object literal sets the prototype), leaving a function marked strict
whose schema forbade an argument the tool declares; properties are built
with Object.fromEntries now.

Tests: the per-tool gate end to end in step-executor (a converted tool's
forced null is dropped, a refused tool's is not), the three nullable
spellings, idempotence over every converted built-in schema, the dropped
annotations, __proto__, and the open-object refusal. Each was checked to
fail against the pre-fix behaviour.

AGENTS.md: corrected 77-of-82 (registry) vs 76-of-82 (payload, reply
overridden by its hand-tuned minLength schema), named the widened-enum
shape (18 properties) as the likeliest first 400 rather than the anyOf
branch (2), and documented the MCP coverage rules.

It also supersedes the note added one commit earlier that the drop is
per request "because carrying the strict-marked names from the request
builder to the parser is more plumbing than the case is worth": the
plumbing is one optional adapter method and one argument, and the case
is a live MCP tool.
…executes

Fusion assumed its pairing: cloud orchestrator, local workers. That is
the right default and the economics the mode was built for, but it is
not the only sensible pairing. Cheap local planning driving capable
cloud executors is a real use case, and so is a big local model
orchestrating a small one. The kinds were baked into three places; none
of them had to be.

- The resolver's worker default still prefers a local provider, but only
  as a default — a pinned leg is honoured whatever its kind, and the
  fallback is now "any provider that is not the other leg" rather than
  "a llama-server or nothing".
- setMode no longer forces the orchestrator onto a cloud provider. An
  explicit pin wins; without one the order is the active provider, then
  the first usable cloud one. It picks the second leg the same way and
  refuses only when there is no second provider to pick.
- The pre-flight counts usable legs instead of requiring one of each: a
  keyed cloud row can answer, a local row with something on disk can
  answer, and fusion needs two of those.
- `no-local-provider` becomes `no-second-provider`, and its sentence
  stops prescribing a kind.

In the composer the two controls are now the two slots. The provider
control is the orchestrator: cloud rows as before, plus `local-llama`
when a model is on disk. The workers control is the second slot: the
models on disk, plus every cloud provider that is not already
orchestrating — fanning out to the model doing the orchestrating buys
nothing and doubles the bill. Picking a local model for the workers also
claims the slot for the local leg, so the pick cannot be quietly ignored
by a pin left on a cloud provider.

The `### fusion` machine line only states slot counts when the local leg
is actually running the workers; with cloud workers there is no slot pool
to describe, and the idle daemon's number would be about the wrong
machine.
Strict decoding and parallel function calls do not compose. OpenAI's
own guidance is explicit: "Structured Outputs is not compatible with
parallel function calls — when a parallel function call is generated,
it may not match supplied schemas. Set `parallel_tool_calls: false`."

`buildOpenAiChatBody` defaulted `parallel_tool_calls` to `true`
(`agent.maxParallelToolCalls` is 8 out of the box), so a provider that
opted into `strictTools` was emitting `strict: true` on 80 tools and
still getting best-effort adherence — the exact symptom the flag
exists to cure. Under the flag the wire now asks for one call per
response; the executor's own batching is untouched, and a provider
without the flag keeps today's value verbatim.

Also corrects two claims in the transform's header that do not hold:

  * `os.git.init` is cited as a tool an explicit null would break, but
    its `optionalString` maps null to undefined before the presence
    check. `memory.profile.set` is the real case — `parseSetOptions`
    gates on `!== undefined` and then demands a boolean, so a null
    turns a good call into a validation error. Cited that instead.
  * "no schema this transform converts turns a nested optional into a
    null the caller did not choose" is false: five do
    (`os.fs.archive.extract.limits.*`, `fusion.delegate.tasks[].*`).
    All five readers happen to treat null as their default, so nothing
    is broken today; the comment now names them, and names the other
    place the drop does not reach — step-executor's two content
    recovery paths build a batch from the grammar parser and use the
    adapter for `nameUnescape` alone.
Mutation-testing the branch found four edits that removed the feature
end to end and left all 936 tests green: dropping `this.strictTools`
from either `buildOpenAiChatBody` call site in `OpenAiProvider`, not
wrapping the tool-call adapter, and deleting `strictTools:
entry.strictTools` from all five OpenAI-shaped factories. The
transform, the body builder and the config parser were each well
covered; the wiring between them was not covered at all.

Adds the two observable ends. `openai-provider.test.ts` asserts the
posted body — streaming and not — carries `function.strict` with the
rewritten schema and `parallel_tool_calls: false`, is byte-identical
with the flag absent or false, and that the wrapped adapter drops a
top-level null (`memory.profile.set.pinned`) while the unwrapped one
keeps it. `cloud-passthroughs.test.ts` gets the `strictTools` row
alongside `maxOutputTokens` and `extraBody`, which is what that file
exists for.

Each of the four mutations now fails at least one test.
plombeer31 and others added 22 commits September 13, 2026 23:25
Alibaba-served Qwen refuses any request with `response_format` unless
the messages contain the word "json" ("'messages' must contain the
word 'json' in some form"), and OpenAI documents the same rule for
JSON mode. The rewriter, vote and link-generator prompts were written
for the llama-server GBNF path and never say it, so on qwen3.6-plus
every one of those calls answered 400.

buildOpenAiChatBody now appends one short instruction when it attaches
`response_format` and the prompt does not already mention JSON. It is
the only place that changes: the llama-server /completion payload is
built elsewhere, so the reflection slot's prompt bytes and KV cache are
untouched; the main agent turn sends no `response_format`, and a
request with `tools` never gets one, so their bodies stay
byte-identical. Tests pin all three.
runWithFallback dropped each failed link's error on advance and rethrew
only the last one. On the common chain [cloud provider, auto-appended
llama-server], a cloud 404 (OpenRouter: "No endpoints found for ...")
followed by a local server that is not running reached the operator as
`Turn failed [transport]: fetch failed`, the trace error row said the
same, and the cloud's own refusal was logged nowhere, not even at debug.

The last link's error is still the one thrown, unmodified: it decides
classification, fallover and the outage wait, and those match on its
class, cause, status and an anchored `fetch failed`, so rewriting its
message would reclassify a bare TypeError as `tool`. The failed links
are kept in a WeakMap beside it instead, found through the cause chain,
which survives the step executor's TransportError re-wrap:

- the TUI, Telegram and Discord failure lines append
  `(after "<id>" failed: <reason>)`; a single-link failure renders
  byte-for-byte as before
- the trace error row carries them as `fallbackFailures` and keeps
  `message` verbatim; `trace show` prints them
- the chain logs every advance at warn with from, to, status and the
  capped reason the switch notice already shows

A turn already on a sticky override never retries the primary, and every
retry of a parked turn is such a call, so the chain also remembers the
primary failure that put the partition there and runWithFallback carries
it onto a failure of the override link. Without that the turn still
ended on a bare `fetch failed` once the five-minute wait ran out.

describeReason and the breaker/partition state move to their own
modules so provider-fallback-chain.ts stays within 300 lines.
Reflection, link generation, voting and the query rewriter run
fire-and-forget after a turn and fail without a word. On hosted
reasoning models, live sessions saw reflection time out 6 of 8 calls,
the rewriter 16 of 16, and the vote runner refuse its schema 8 of 8.
Those outcomes reached logs, metrics and trace rows but never the
operator, and an empty link graph means the consolidator never distils
a lesson, which nobody notices from the chat.

A pure tracker (src/memory/health) counts consecutive timeout/failed
outcomes per session and sub-call. ok, none and skipped reset the
streak; aborted is neutral, because the next turn aborts a stale
reflection by design. At three in a row it returns one warning per
(session, kind) for the runtime's lifetime. The text names the knob:
the per-call timeout key for timeouts, or the sub-call's switch plus
the last failure reason (one line, credential shapes masked, capped)
for failures. It quotes no default values, since those are moving.

Bootstrap feeds the tracker from the existing emitTrace hooks, after
the per-call trace row is written and whether or not the session is
traced, and from the vote runner's run() result, which now carries the
failure reason. The rewriter's trace event carries one too.

The warning is logged at warn and emitted as a memory_health_warning
event on the sub-call's own session: the trace recorder writes a row,
`trace show` prints it, issue reports keep it at errors level without
the reason, and the TUI shows a warn-styled system notice plus a feed
line for the session on screen. Telegram and Discord get nothing,
matching the fallover announcement.
…llback

#417 moved complete()'s body build into a closure handed to
sendWithStructuredOutputFallback; thread this.providerPreferences through
that closure (after strictTools) so unary sub-calls keep OpenRouter
provider routing on both the first send and the prompt-only retry.
#414 appends a JSON instruction to the prompt of any body that carries
response_format; #417's prompt-only retry drops response_format, so its
prompt is the caller's own. The retry test asserted the two bodies were
identical apart from response_format; assert the JSON mention on the
first send and the original prompt on the retry instead. No runtime
change.
…ned ones

The ### profile section had one bound: render every fact sorted by key,
then cut the string at memory.profile.maxTokens. In a long-running store
the cut landed mid-section on every turn, sliced the last fact mid-value
and removed whatever sorted late, pinned consent and security facts
included, with a bare "[truncated]" inside the prompt as the only trace
(#407). profile_facts was also the one memory table without maxEntries.

- Render pinned facts first, then contextual ones (key order inside each
  group), and pack the section a whole line at a time: a line that does
  not fit is skipped, the last line counts what was left out, and
  BuiltPrompt.profileClip carries rendered / dropped / pinnedDropped.
- AgentLoop turns a clip into a warn log and a profile_clipped event
  (trace row, trace show line, runtime_info line in the TUI feed), once
  per session and again only when the number of pinned facts left out
  changes. The total moves with every message because contextual facts
  are keyword-gated, so keying on it would warn most turns. Ephemeral
  fusion-worker turns skip it.
- memory.profile.maxEntries (default 500) caps active unpinned facts. A
  set() past it deletes the lowest-utility unpinned facts (vote_score,
  then updated_at, then id; never the row being written) inside the
  insert transaction, the same delete remove() does, so superseded
  history stays readable. Pinned facts are never counted or evicted.
  Evictions log counts and write a profile_facts_evicted trace row whose
  keys /report strips below the full level.

The key is additive with a default, so the config version stays at 64,
as agent.task, agent.providerWait and agent.conversationMaxPairs did.
…ent stop

When grammy's bot.start() settled without a stop() we asked for, the
channel released its lock and went `down` for good. The process kept
running and looked healthy while every Telegram message went unanswered
until someone restarted it. The Discord gateway in the same codebase has
always reconnected.

An unexpected stop now arms one unref'd retry of start() on the Discord
gateway's full-jitter backoff. The backoff moves to
src/channels/reconnect-backoff.ts and is re-exported from the Discord
transport, so its import path, behaviour and tests are unchanged. While
waiting, the channel reports `down` with "polling stopped: <reason> —
reconnecting in Ns (attempt n)": a new ChannelState would ripple through
the Integrations hub, the setup panel, the status lines and the sidecar
for no gain. Each attempt is a warn line with the scrubbed reason, so
`serve` leaves a post-mortem trail on stderr.

No retry can fix a Bot API 401 (revoked token), 404 (malformed token) or
409 (another process polls the bot, and retrying would fight it), nor a
lock another process holds. Those still end `down` with the plain reason.
The attempt count starts over only after a full 30 s long-poll round of
`up`, so a stop that recurs right after every start backs off to the cap
instead of spinning. A first start() that fails stays `down`, as before.

stop() disarms the timer and bumps a generation that a start() still
awaiting Telegram checks before committing, so neither a waiting nor an
in-flight retry can resurrect a stopped channel. Any other start()
supersedes the waiting retry; restart() and setToken() treat a waiting
channel as running. An unexpected stop also drops the per-session
approval bindings: they pointed at the dead bot's bridge and would have
posted keyboards whose clicks the reconnected bot ignores.

Refs #409: the reconnect covers every non-fatal loop exit; a 409/401 exit stays terminal by
design and is now logged with its reason, so the issue stays open until that reason is confirmed.
… drop pinned ones

# Conflicts:
#	src/cli/trace-formatter.test.ts
#	src/cli/trace-formatter.ts
#	src/tui/issue-report/trace-redaction.ts
Integrates #411#421 for v0.6.1, including the two rc-only reconciliations (#413×#417, #414×#417).
Composio has shipped in the Integrations tab since v0.5.6 but was not
mentioned in the README. Adds a capability-table row and a details
section covering setup (Integrations tab or COMPOSIO_API_KEY in .env),
the enabled kill switch, the MCP tool-router seam, and the fact that
connected-app tokens live on Composio's infrastructure.
Brings the desktop agent up to v0.6.1: config format 65, Fusion, the
sub-call partition fix and the rest of main since the branch point.

The one real conflict is the fallback failure contract. main keeps the
last link's error untouched and records the earlier links beside it
(attachFailedAttempts); atagdesktop threw the primary's error instead.
Take main's runtime contract, and report the first recorded link at the
HTTP boundary: loop_failed over SSE now names the provider the operator
picked, with its own message and category, and lists every failed link
in `fallback_failures` on the extensions frame. A single-link failure is
reported exactly as before, so the desktop failure line keeps naming the
chosen provider's refusal rather than the auto-appended llama-server's
`fetch failed`.
fusion.delegate emits fusion_worker in the parent session's frame, so the
events already reach the HTTP turn's hook through TurnController.emit —
and buildStreamEventHook dropped them. A desktop host saw nothing for the
minutes a fan-out held the turn.

With x-atomic-extensions: 1 each event is now an `event: fusion_worker`
frame: {object: atomic.fusion_worker, session_id, task_id, title, phase,
role, model?, tool?, step_count?, duration_ms?, summary?}. An absent role
is sent as worker; absent fields stay absent. OpenAI-compatible streams
get nothing.
os.shell.run summaries were the compressor's 400-character default with
the whole command line echoed in front, and the overflow cut kept the
FRONT of that. A multi-line bash -c script filled the budget on its own,
so the model (and the desktop's tool card) got the command, exit: 0, a
few bytes and '… [truncated]'. On the operator's two turns of 2026-09-15,
25 of 29 shell results were flagged truncated, 21 of them cut at the cap,
and one turn spent steps 13-20 re-running near-identical verification
scripts it could not read the output of.

- compressToolResult takes a pinned head and an overflow mode; 'tail'
  keeps head + key-error line + the END of the output (default stays
  'head', so pages, files and documents are cut as before).
- shell: the header names the command by its first line (<=200 chars;
  the full command is the tool call right above it in the transcript),
  output keeps its tail, budget 2000 chars / 40 lines (still well under
  the 4000-char conversation render cap).
- a one-line error is no longer repeated as its own key: line, and a
  Python traceback is keyed by its exception line, not by its header.
The aimlapi and openrouter factories set supportsVision from the provider
entry alone, defaulting to true, and nothing the desktop or the wizard
writes carries the flag. So deepseek/deepseek-v4-flash on AI/ML API -
supportsVision: false in our own bundled catalogue - was vision-capable:
on 2026-09-15 vision.describe sent screenshots to it three times across
two turns and got '400 Validation failed' each time, a step spent per try.

- the factories consult the bundled catalogue for the model describeImage
  actually calls (defaultChatModel); an explicit entry flag still wins, and
  an id the catalogue does not know keeps the old default.
- vision.describe's 'not available' result says the model does not take
  images and not to retry in this turn.
- its error summaries get 1200 characters, so a provider's reason at the
  end of a JSON body is not cut mid-sentence.
GET /api/sessions/{id} had no trace of an approval, so a host that drew the
card live could never put it back on reload: a reopened chat and the live
one disagreed about what the turn contained.

- approval-ledger: an AsyncLocalStorage ledger per tool invocation. The gate
  captures it when a prompt goes out and writes the verdict on resolve, so
  concurrent calls of the same tool in one batch each get their own verdict
  whatever context the resolution arrives from. Auto-approvals and
  refuse-policy denials put nothing to anyone and record nothing.
- the batch executor stamps a call's result with its approvals; the step
  executor copies them onto the tool_result turn as approvals: [{verdict,
  category, at}]. Transcript-only - the prompt renderer ignores the field.
The stream hook skipped every `assistant_reply` for the rest of the turn
once any delta had streamed. Replies that never stream — the max-steps stop
message, the loop breaker's answer, a reply from a non-streamed retry — were
therefore dropped live, and the turn read as cut off at whatever preamble
streamed last.

The skip is now per step: a reply the same step already streamed is not
repeated, the rest of a partially streamed reply is sent, and a reply that
never streamed is sent whole, set apart from earlier text by a blank line.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants