Desktop agent: merge main (v0.6.1) into atagdesktop - #451
Open
plombeer31 wants to merge 99 commits into
Open
plombeer31 wants to merge 99 commits into
plombeer31 wants to merge 99 commits into
Conversation
… 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.
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.
…est's provider routing
…mode and Qwen reject
… hosted reasoning models
…endpoint refuses structured outputs
…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.
… a transient stop
… drop pinned ones # Conflicts: # src/cli/trace-formatter.test.ts # src/cli/trace-formatter.ts # src/tui/issue-report/trace-redaction.ts
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
mainsinceatagdesktopbranched.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
mainthrows 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).atagdesktopthrew 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_failedover SSE now carries the provider the operator picked: its own message, its owncategory(classifyFailure), andfallback_failures(every failed link) on the extensions frame.The desktop's failure line reads
error+categoryfrom that frame, so it keeps naming the chosen provider's refusal.Checks
tsc --noEmitclean.vitest:src/llm/fallback, newsrc/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'sbetter-sqlite3is built for ABI 127.build→bundle:sea→build-binary→package):atomic-agent 0.6.1, andtask listopens sqlite on a throwaway state.facelift/integration(desktop: Soft Tactile redesign — every screen, integrated #450), built withnpm run dist(ad-hoc signed asai.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:/api/coding-modeUpdate: 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_workerover SSE (39b01c13). The worker progress event is forwarded as an extensions-only named frame, so the desktop can show live Fusion workers.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.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 repeatedvision.describe400 ondeepseek-v4-flash. An explicit config setting still wins. Provider error text is kept up to 1,200 characters.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.91378043). The stream hook skipped everyassistant_replyfor 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
tscis clean.src/http(stream, failure, Fusion-worker and reply-frame suites, chat completions),src/llm/fallback,src/tools/osandsrc/tools/vision.atomic-agent 0.6.1, andtask listopens sqlite.turn-order.drive --receipts8/8,fusion.drive17/17. The packaged app from the DMG passes 9/9 on a copy of a live desktop state.src/sidecar/send-message-concurrency.test.tsfails in the full suite with or without these changes.