Skip to content

release: integrate the 15 open PRs (render, fusion, triage batch) - #405

Merged
plombeer31 merged 61 commits into
mainfrom
qa/all-2026-09-10
Sep 11, 2026
Merged

release: integrate the 15 open PRs (render, fusion, triage batch)#405
plombeer31 merged 61 commits into
mainfrom
qa/all-2026-09-10

Conversation

@plombeer31

Copy link
Copy Markdown
Collaborator

The tested integration of every open PR: #390, #391, #392#397, #398, #399#402, #403, #404.

Merged as one branch rather than fifteen times into main, because two pairs genuinely collide and the resolution is what was tested:

What is in it

#390 Ink's incremental renderer off — it lost the frame on a session swap
#391 Fusion is plan → delegate → review, enforced: no mutation before the turn delegates
#392#397, #399#402 The triage batch: Telegram intraword emphasis, empty native-tools completion recovery, /model in Telegram and Discord, strict tool schemas, serve docs, trace keeps its tail, image type from bytes, Telegram lock-release guard, sender identity, strict-tools provider flag
#398 The machine sizes the worker pool (parallel: "auto"), the model sizes each fan-out
#403 Fusion's two slots take either kind, plus the boot fix for a leg pin the UI can write
#404 download-spawn stops betting that pid 777 is dead

Verified

npx tsc --noEmit clean. Full suite on this branch: 9383 passed, 3 skipped, 1 failedsrc/sidecar/send-message-concurrency.test.ts, which fails identically on v0.5.6 on the same machine and passes in CI. Every one of the fifteen PRs is green in its own CI.

Driven against the built binary, not just the sources: render paint map 33/33 rows preserved across a session swap (was 33 → 1), fusion's legs swap from the composer and both halves of the route line name the models that actually run, and the app relaunches on that swapped config instead of dying in config validation.

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.
The "in a row" semantics have three reset sites and only one of them —
the success path — was pinned by a test. Deleting either of the other
two, in the truncation-retry branch and in the parse-recovery branch,
left `npx vitest run src/agent src/tui src/tracing` entirely green, so
the claim that consecutiveness is enforced rather than assumed was
two-thirds unverified. These two are also the risky half: they are the
resets that can RAISE the number of wasted inferences in a turn.

Two loop tests, one per site: an empty, then a completion of that shape,
then a second empty that gets its own retry only because the shape in
between cleared the count.

Both fail against `origin/main`, and each fails when only its own
`emptyRecoveries = 0` is deleted — parse-branch deletion fails only the
"rejected completion" test, truncation-branch deletion only the "cut
reply" one.

Reaching a loop-level parse failure on `native_tools` needs bad tool-call
arguments twice: plain `content` is wrapped as a `reply` by the transport
fallback, and the first bad batch is consumed by the step's own one-shot
repair.
…ng required nulls

Four defects found re-reviewing the branch, each reproduced before it
was fixed.

Tagged-tool providers. A `qwen-openai-compatible` link answers with
`<tool_call>` prose that we decode ourselves; no strict decoder is
involved, so nothing stops the model omitting an optional. But
`indexOfferedTools` read the strict payload's `required` literally —
and the rewrite puts EVERY property there — so `coerceArguments`
rejected every realistic tagged call for a missing parameter,
`parseSource` returned null, and the tool call collapsed into prose.
Turning the level on made tool calling strictly worse for that
operator, which is the outcome this design says it refuses. The
decoder now reads the strict spelling the way strict means it: a
listed property that admits `null` is optional. That costs one
presence check the tool's own validator makes again, and it holds on
whichever link the fallback chain serves, not just the configured one.

The null-drop was keyed per TOOL while the rewrite is per PROPERTY. An
argument that was already `required` is emitted byte-identical —
`z.string().nullable()` through the MCP SDK is `anyOf: [{string},
{null}]` listed in `required` — yet its null was still deleted because
some other property on the same tool got widened, handing that server
a call missing a required field. `strictToolNames` becomes
`strictWidenedArgs`: each rewritten function mapped to the arguments
whose optionality the rewrite actually erased. Both new tests fail
without the change.

The nesting bound did not reach the `anyOf` path, which recursed at
the caller's depth. A self-referential union still overflowed the
stack out of `descriptorsToOpenAiTools` — the exact failure the bound
was added for — and an arbitrarily deep union chain still converted.

Every emitted node is a spread of the node that came in, so an
allowlisted keyword the node's SHAPE has no rule for rode out
unconverted into a schema marked strict: `enum` on an object emitted
its raw sub-objects verbatim, and `items` on an object (or the object
keywords on an array, or any of them on a union) were the same bug
with different keys. Each shape now refuses its own strays, as
`convertScalar` always did.

Also: `modelWantsStrictTools` — the only path from the operator's
config to the wire — moves out of `bootstrap.ts` into
`model-strict-tools.ts` with direct tests, since reverting it left
every runtime test green; the nine `github-tool-args-schemas.ts`
entries join the pinned coverage sample (82 schemas, 77 convert, 76
emitted functions strict); and both directions of one inference share
a single build instead of converting all ~80 schemas twice.

Flag off is still byte-identical to the previous payload, checked
against origin/main's adapter.
The previous commit moved the emphasis word guard from `\w` to
`[\p{L}\p{N}_]` so it would apply to Cyrillic prose. CJK ideographs and
Hangul are `\p{L}` too, and Chinese, Japanese, Korean and Thai are
written without spaces between words, so in those scripts *every*
emphasis run is flanked by letters: the guard stopped being a heuristic
that tells arithmetic from emphasis and became a blanket disable of
single-`*` italics for the whole language. `这是*重点*内容` came out
with the asterisks visible where it used to render `<i>`, and where
CommonMark renders `<em>`. `**bold**` was unaffected, so the loss was
specific to italics.

Exempt those scripts from the `*` word guard. The cost on the
arithmetic side is small, because the guard only ever inspects the
character immediately beside the delimiter and real code puts an ASCII
operand there — `这是 20*log10(abs(15-1*25)) 的结果` is still literal.
`_` is deliberately not exempted: CommonMark forbids intraword `_` in
every script, CJK included.

Two smaller corrections in the same rule:

- Count `\p{M}` as a word character. A combining mark is part of the
  word it attaches to, and without it the keycap `1️⃣*x*` opened
  emphasis (U+20E3 is a mark, not a digit) where the bare `1*x*` did
  not.

- Refuse a candidate whose delimiter sits inside a tag an earlier pass
  emitted. `tagsBalanced` could not see this case: a `*` inside a URL
  splits `<a href="…">` down the middle, leaving no complete tag in the
  body to be unbalanced, so `*[a](http://x/*)*` was rendered as
  `<i>&lt;a href="http://x/</i>">a</a>*` — an orphan `</a>` that
  Telegram answers with a 400. That one reproduces on main too; the
  comment claiming an emitted tag can never be crossed was wrong, and
  now both the claim and the behaviour match.

Measured on the same sweeps as before: 0 ill-formed outputs over the
2,015,538 strings from `* _ space a b c` up to length 8 and over
`* _ [ ] ( ) : a` up to length 6; ill-formed count equal to main on an
alphabet containing raw `<`/`>` (3,584 either way, all from the
pre-existing `escapeNonTagText` hole); and still no input that is
emphasised here but left literal by main, so the rule remains a strict
narrowing of shipped behaviour.
…ode change

Second review round on #394. Four substantive findings.

1. `/model local-llama <model-id>` was accepted, changed no inference,
   and permanently renamed the model everywhere with no chat-side undo.
   The `llama-server` factory never reads `entry.defaultChatModel`
   (register-built-in-providers.ts), but `resolveActiveModelName()` in
   bootstrap.ts reads it FIRST — ahead of `localModels.managed.modelId`
   — so the pin fed the report, the pricing key and every `message_sent`
   event while the daemon kept serving its own GGUF. The TUI cannot
   reach that state (`openChatModelPicker` / `ensureInlineModels` refuse
   non-cloud kinds); now neither can a chat. `MODEL_PIN_IGNORED_KINDS`
   refuses the pin and points at the Local Models tab.

2. `/model <provider>` could drop a fusion deployment out of fusion —
   removing `fusion.delegate` and the `### fusion` guidance from every
   session and invalidating every KV prefix — and answer "Now on
   local-llama." Leaving fusion by switching provider is by design
   (`resolveRunMode` derives the effective mode from
   `activeTextProvider`); having no way to see it is not. The bare
   report now carries a `Run mode:` line built from the same
   `describeRunMode` the TUI's chip uses, and a switch that crosses into
   or out of fusion says so, naming the provider that restores it.
   Local <-> Cloud stays silent: the reply already names the provider.

3. On the default local-first install, bare `/model` answered "provider
   default" for a managed daemon whose GGUF id the config knows.
   `displayModelOf` now falls back to `localModels.managed.modelId` for
   a `llama-server` entry — the same first legs, in the same order, as
   `resolveActiveModelName()`. Display only: the session stamp keeps
   using the config pin, because a stamp carrying a model on a
   llama-server entry would make the TUI's session restore write it into
   `defaultChatModel` on reopen (session-model-restore.ts) — the exact
   poisoning (1) prevents.

4. The API-key refusal and the report's flag were keyed on provider kind
   alone, so known-service presets (Groq, Nous, Anthropic — all stored
   as `openai-compatible` with their own `apiKeyEnvVar`) were shown as
   usable and switched to with the variable unset, after which every
   turn 401s silently. An entry that declares an `apiKeyEnvVar` has said
   it needs a key; a bare keyless compat entry (LM Studio, Ollama) has
   not, and keeps working. The refusal and the report name the variable.
   `apiKeyEnvVar` was missing from `AtomicAgentConfig.llm.providers[]`
   although `parseLlmProviders` has always carried it — added.

Plus the two nits: the Discord suite gains the one-token
`<provider>/<model-id>` case it was missing, so the two files are the
symmetric pair they claim to be (25 tests each), and
`restoreProviderDefaultChatModelInConfig` gains direct tests in the file
that owns it, including the unset branch `setProviderDefaultChatModel`
cannot express.

Tests: 7 of the 9 new Telegram assertions fail against this branch's own
src (verified by reverting model-command.ts); the two that pass either
way are deliberate controls — a preset with its key set must still
switch, and an ordinary Local -> Cloud switch must stay quiet.
The previous commit added `\p{M}` to the emphasis word guard so the
keycap `1️⃣*x*` would behave like the bare `1*x*`. VARIATION
SELECTOR-16 is `\p{M}` too, and it is what turns a bare symbol into an
emoji, so the guard silently stopped single-`*` and `_` italics after
every emoji spelled with one: `⚠️*Do not* run this` came out with the
asterisks visible where `⚠*Do not*` rendered `<i>`, and where both
`origin/main` and the two earlier commits on this branch rendered
`<i>`. Over U+2000–U+2BFF that is 2,791 of 3,072 code points losing
italics the moment the selector is appended — ⚠️ ✔️ ⭐️ ❤️ ▶️ ℹ️ ❗️ ⏱️
among them.

Accept a variation selector as the character before an opening
delimiter, and only there. Before an opening delimiter the selector
belongs to whatever precedes it, and judging the run by the selector
instead of by its base is the bug. After a closing delimiter it
belongs to the delimiter itself — `*` is an emoji base, so in `*x*️⃣`
the trailing `*️⃣` is a keycap, and reading it as a closing delimiter
would emit `<i>x</i>️⃣` and delete the `*` out of an emoji. There the
mark keeps blocking.

U+20E3 COMBINING ENCLOSING KEYCAP is not a variation selector, so
`1️⃣*x*` stays literal; combining marks that really are part of a word
still block, which the two new negative pins cover (Devanagari `की*x*`
and decomposed `é*x*` — both regress if `\p{M}` is simply dropped).

Sweeps re-run: over `* _ space a b c` to length 8 (2,015,539 strings),
`* _ space a 这 点` to length 7, `* _ [ ] ( ) : space a` to length 6 and
`* _ < > space a b` to length 6, output is byte-identical to the
previous commit — the change touches nothing on ASCII marker soup — and
ill-formed counts are unchanged (0, 0, 0, and 3,584 equal to main from
the pre-existing raw-`<` hole). On an alphabet containing `⚠️` and
`1️⃣` it differs from the previous commit on 105 of 9,331 strings, all
of them in the looser direction and none of them emphasising anything
`origin/main` leaves literal, so the rule is still a strict narrowing
of shipped behaviour.

Also correct the residual-risk comment. It named only the user-typed
raw `<` as the remaining source of malformed output; `renderBold` and
`renderStrikethrough` run after `renderLinks` with no `tagMask` of
their own, so `**` and `~~` can still cross an emitted anchor —
`convert("[**](tg:*)**")` is `<a href="tg:*"><b></a></b>` with no `<`
typed anywhere. Pre-existing and identical on main; the comment now
says so.
Third review round on #394.

1. The "never throws" contract was still not held. The post-switch
   config re-read was guarded, but the *entry* `getConfig()` /
   `resolveLlmConfig()` was outside every guard — and that is the read
   most likely to fail, because `/model`'s own
   `setActiveTextProviderInConfig` calls `resetConfigCache()`, so every
   invocation goes back to disk. A hand-edited or torn config therefore
   rejected the whole command: no reply at all on Telegram, an
   unhandled rejection on Discord's bare `void this.onDispatch(...)`.
   Reproduced with `llm.activeTextProvider` naming an entry that is not
   in `llm.providers`. The guarantee is now enforced in one place —
   `runModelCommand` wraps the whole body — rather than promised leg by
   leg; the legs with something better to say still catch first.

2. The managed-model fallback in `displayModelOf` was applied to every
   `llama-server` entry, not the active one. Its justification is
   parity with `resolveActiveModelName()`, and that only holds for the
   active provider — `bootstrap` computes that name for
   `activeTextProvider` alone, while the report walks the whole list.
   `localModels.managed` describes one daemon, so a second local entry
   (a box on the LAN; `resolve-run-mode.ts` picks the worker leg with a
   `find`, so more than one is expected) was reported as running a GGUF
   it has never seen. A non-active local entry now reads "provider
   default", which is what the config actually says about it. The
   `localModels.mode` leg is deliberately left alone: gating on it
   would make the channel disagree with the model name in every
   `message_sent` event and in the cost lookup.

3. The report enumerated every configured provider with no cap and no
   clip. Discord splits a message at 2000 characters and Telegram at
   4096, so an install with a dozen `openai-compatible` entries turned
   one answer into several messages — and on Telegram a chunk a second
   429 drops is simply lost. Sixty providers produced a 3962-character
   Discord report. The enumeration is now fitted to a budget under the
   tighter of the two limits, the overflow is counted rather than
   printed, and the active entry's line is budgeted up front so the
   cap can never drop the provider the report is about. Unbounded
   names — model ids, `apiKeyEnvVar`, the typed token — are clipped;
   provider ids are not, because `PROVIDER_ID_RE` already caps them at
   32 characters. The `Configured:` list in the unknown-provider
   refusal is capped the same way.

Plus the nit: the unreachable `return head` in `runModeChangeNote` is
gone, the fusion branch being the only shape the two guards above can
leave.

Tests: 5 new cases per channel file (30 each, still symmetric). All 10
fail against this branch's own src with `model-command.ts` reverted —
the config-parse case fails by rejecting `handleInboundText` outright,
which is the defect.
…ocal one

Driving the swap on a built binary: pick `local-llama` for the
orchestrator and the strip read `local-llama ⇄ qwen-3.5-4b · up to 2
workers` while the config said the workers were openrouter. Both halves
of the right-hand side were describing the local daemon regardless of
which leg the workers were on — the same class of lie as the chip that
said fusion while the runtime was cloud.

Three places, one cause each:

- `resolveRunMode` filled `workerModel` from `managedModelId`
  unconditionally. That is the model the LOCAL daemon serves, so it now
  only answers for a local worker leg; a cloud leg reports its own
  default chat model.
- The strip's worker half read the local models panel directly. It now
  labels from the worker leg's own provider row, the way the
  orchestrator half already did.
- `up to N workers` is llama-server's request-slot count, which a cloud
  leg does not have. It reads `cloud workers` there instead of quoting a
  number about an idle daemon.
…andidates

Two of the three answers that interpolate a name the config schema does
not bound were left uncapped by the previous round, and both are the
*likeliest* to carry a long one, because the operator has just typed it:

- the switch confirmation ("Now on X · <model>"), and
- the llama-server pin refusal ("Pinning <model> would rename it …").

A Discord-legal 2000-character `/model openrouter <1982 m's>` answered in
2041 characters and `/model local-llama <1981 m's>` in 2352, both past
`DISCORD_MESSAGE_LIMIT`, so `DiscordApi.sendMessage` chunked each into
two. A third path had the same hole: `resolveTarget`'s no-API-key refusal
printed `apiKeyEnvVar` whole, while `providerLine` clips it. All three now
go through `clipName`, so the 48-character rule holds everywhere a name is
printed. The value written to the config is untouched — clipping is a
display concern, and a test asserts the stored pin is still the whole id.

The other half is a regression this round introduced. `joinIds`' flat
twelve-entry cap was right for the unknown-provider `Configured:` list,
which is orientation, but wrong for the ambiguous-prefix refusal, whose
entire job is "say which one": on an install with sixty `compat-*`
providers it answered "compat matches compat-provider-0 … and 48 more —
say which one", hiding the very information the sentence asks the operator
to act on, and buying nothing — the full list was 1220 characters on
Telegram and 1342 on Discord, inside both limits. Ambiguous candidates are
now fitted to a character budget instead: everything that fits is printed,
and only an install past one message's worth gets a count, together with
the one thing that shortens the list from a chat.

Five new cases per channel, symmetric across both surfaces.
OpenAI documents Structured Outputs as not compatible with parallel
function calls — a parallel call generated under strict mode "may not
match supplied schemas" — and says to send `parallel_tool_calls: false`.
This branch marked every convertible tool `strict: true` and left the
flag at its default `true`, so an operator who turned the level on still
got best-effort adherence: the exact symptom the feature exists to cure.

Found by the parallel work on PR #402, which fixed it at the body
builder off its own provider flag. Implemented here off the emitted
tools array instead, because strict is granted per tool: an adapter can
ignore the option and a descriptor set can convert nothing, and neither
should silently lose parallel calls for a request that is not
constrained at all. `hasStrictFunctionTools` is the one predicate, read
in two places that therefore cannot disagree — `buildLlmStreamParams`
makes the decision, so the CompletionRequest is honest about it, and
`buildOpenAiChatBody` is the floor under every other caller of the body
builder.

The executor's own `maxParallelToolCalls` batching is untouched: a model
that emits several calls anyway is planned and run exactly as before.
76 of the 82 emitted functions carried `strict: true`. Three of the six
refusals were over bounds the strict compiler ignores anyway — `reply`
(`minLength: 1`), `vision.describe` (`maxItems`), `fusion.delegate`
(`minItems`/`maxItems`/`minimum`) — so the refusal bought nothing: the
tool came out unconstrained AND unbounded instead of constrained and
unbounded, and `reply` is the one tool most worth constraining.

So a value-range keyword is now stripped rather than refused over. It is
safe for the reason `default-tool-args-schemas.ts` gives in its own
header: these schemas guard shape, and every bound they carry is
re-checked by the tool's own parser, which is what rejects a bad call
today — checked one by one for the three recovered (the batch
validator's non-empty `text` rule, `maxImagesPerCall`,
`parseDelegateArgs`). `const` is deliberately not on the list: it pins a
value the way a one-member `enum` does, so dropping it would widen what
the tool accepts, and it stays a refusal.

The fourth, `os.fs.archive.extract`, was refused over a nested `limits`
that declares properties AND says `additionalProperties: true`. An
object in that shape is now closed: the author wrote both halves, the
declared keys are the whole documented contract, the model is shown
nothing else, and `parseLimits` reads exactly those three keys. An
ABSENT `additionalProperties` still refuses — same semantics, but it is
what pydantic/FastMCP emit for every model, so refusing there keeps this
rule to schemas somebody actually typed `true` into. A zero-property
object refuses either way, so the open-object fallback is still never
published as a zero-argument tool.

Coverage measured over the real DEFAULT_TOOL_DESCRIPTORS: 80 of 82
emitted functions strict, refusing only `os.http.request` and
`mcp.prompt.get`, where the map IS the payload. Both counts are now
pinned — the registry pin alone would have missed `reply`, whose schema
the adapter hand-writes.

Nothing about the null-drop bookkeeping moves: `strictWidenedProperties`
reads the original schema's `properties` and `required` and nothing
else, and a bound says nothing about either. Pinned as its own test.
The coverage idea came from the parallel work on PR #402.
Reverts 99faa2d. That commit was an optional coverage improvement —
76 to 80 of the 82 emitted functions — taken on the condition that it
was low-risk. It was not.

Stripping bounds made exactly two more built-ins convert,
`fusion.delegate` and `os.fs.archive.extract`, and those two are the
only built-ins whose strict schema contains a NESTED object
(`tasks[]`, `limits`). `indexOfferedTools` — the fix two commits back
that stops a strict payload's inflated `required` from rejecting every
realistic `<tool_call>` — rewrites the TOP-LEVEL `required` and
nothing else. So on a `qwen-openai-compatible` link with the level on,
a `fusion.delegate` fan-out written the way a model actually writes
one (no `deliverable`, no `files` on every task) failed the offered-
tool check and the whole call collapsed back into prose. That is the
precise failure the earlier commit exists to prevent, re-opened one
level down.

Teaching `indexOfferedTools` to recurse into nested objects and array
items is the real fix and a reasonable change on its own. It is not
this branch's change: this chain has now had a fixup introduce a new
problem in four consecutive rounds, and trading a verified-sound
converter for four more tools is not a trade worth making twice.

Coverage returns to 77 of 82 registered schemas and 76 of 82 emitted
functions, refusing `fusion.delegate`, `vision.describe`,
`os.http.request`, `mcp.prompt.get`, `os.fs.archive.extract` and
`reply`. `parallel_tool_calls: false` (fed385a) is untouched — it is
a separate correctness port and nothing here implicates it.

The premise both top-level walks rest on — no schema we convert has a
nested object — is now pinned over the real emitted payload instead of
being asserted in a comment, so the next attempt at this fails in the
suite rather than on somebody's tagged link. `dropNullArgs`' header
says whose premise it is and what a future nesting built-in owes both
readers.
Drops the stripped-bounds and `canClose` paragraphs, restores the
coverage and widened-enum counts to what the branch actually emits
(77 of 82 registered / 76 of 82 emitted, 18 widened enums), and says
plainly that nested nulls are left alone because nothing convertible
nests, not because nesting is handled — the same premise the tagged-
call narrowing walks on, now pinned.

Adds the contract probe to "what this does not cover":
`run-contract-probe.ts` builds its own body and sends one unmarked
tool, so no preflight has ever put a `strict` function, a widened enum
or the `parallel_tool_calls` floor in front of a real endpoint. That
is where a first 400 would be cheapest to catch.
The strict-tool-schema section stated the premise the nested-null undo
and the tagged-call narrowing both rest on as a universal: "no schema
this converts contains a nested object". That is true of the built-ins,
which is what the pin walks, and false of a third-party MCP inputSchema,
which is exactly the class the allowlist was widened to accept. On a
qwen-openai-compatible link such a schema converts, its nested required
is inflated, indexOfferedTools narrows only the top level, and the
tagged call collapses into prose - the same failure the narrowing
exists to prevent, one level down.

Scope both sentences to the built-ins, say which walk covers what, and
name the MCP nesting case in "what this does not cover" alongside the
fallback chain and the contract probe. Recursing indexOfferedTools and
dropNullArgs together is the change that closes it, and is also what
would let the tools refused over bounds be recovered safely.

No code change.
It read `up to 2 workers`, and on a narrow terminal it collided with the
steer hint — `up to 2 work⏎ steer`. The columns bought nothing: the
number is the machine's capacity, not a choice anyone makes, and how
many workers a turn actually spends is the orchestrator's call on that
turn. Both legs are already named by the model segment (`A ⇄ B`).

The worker slot itself stays: it is still one ←/→ away inside the
composer popup, which is where the ←/→ walk has always included it.
Removed the whole prop chain rather than passing null through it —
prompt-shell, prompt-meta-bar, ComposerMetaControls and the selector.
Two v63s and two strict-tools paths met here. The slot-count change took
v63 first, so #402's provider flag is renumbered v64. In the request
builder the two mechanisms are one: the provider flag transforms the
tool array, and the parallel_tool_calls floor is keyed to the array that
actually goes on the wire — which covers both the flag and a caller that
marked tools strict itself. Both test suites kept.
`download-spawn > spawns a detached copy…` failed twice in CI this week
on unrelated branches, each time with

  expected { version: 1, …(17) } to match object { pid: 777, status: 'interrupted' }

The fake spawn reported pid 777 and the assertion wanted the seeded
record to read back `interrupted` — which is `readDownloadJob`
reclassifying a `running` record whose pid is gone. So the test was
asserting that pid 777 does not exist. On a laptop it does not; on a
busy CI container it does, and then the record comes back `running`.

The file already had `DEAD_PID` (2,000,000,000) for exactly this, above
the ceiling Linux hands out. The fake spawn now reports that.
@plombeer31
plombeer31 merged commit 753b302 into main Sep 11, 2026
7 checks passed
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.

1 participant