fix(agent): native and narrated tool calls reach the client, runaway text-channel calls are cut on both /v1/messages and /v1/chat/completions; MODEL_MAP alias mapping with a dashboard editor - #165
Conversation
Qwen's native function_call frames stream `arguments` as a cumulative
snapshot (final one doubled, no function_id, parallel calls arriving in
series). The accumulator only knew OpenAI deltas: index-0 `+=` turned one
call into invalid JSON and two calls into a concatenated name
(`unknown_tool: EditReadBashWrite`), so 100% of native calls died.
tool-prompt.js createNativeToolCallAccumulator:
- pushNativeSnapshot({name, arguments, phase, functionId}) with REPLACE
semantics and the S1-S5 split predicate (default merge), longest
coherent snapshot on regression (warn snapshot_regression), per-round
byte-identical reopen dropped.
- closeByName (FIFO claim of the named result frame), closeOpen(reason),
judge-on-close: unknown_tool (platform-own = functionId or non-answer
phase, or not in allowlist, allowlist empty fails closed),
invalid_arguments (bad JSON / non-object), truncated_native_call
(open at round_end), missing_tool_name, schema_mismatch (advisory:
only missing `required` rejects; extra keys warn and emit).
- takeCompleted() drains gated, not-yet-emitted client calls once with a
fresh UUID id; finalize() is single-shot for legacy consumers.
- batchState()/hasOpenClientCalls() expose the early-stop tally.
- ANSWER_PHASES now lives here and is exported; chat-helpers imports it
(it already depends on tool-prompt, the reverse would be a cycle).
- push(deltas) OpenAI behaviour unchanged.
sse.js consumeSSEStream(stream, onFrame, { shouldStop }): predicate
checked after each frame; on true, install a no-op 'error' listener and
break the for-await (iterator return() destroys the source), skip
decoder.end(), return { ..., completed: true, stopped: true }.
anthropic.js describeToolErrors/buildToolErrorRetryHint count the new
types and add one hint branch for invalid arguments; both exported for
tests.
Tests: 325 -> 344 (tool-prompt +14, sse +3, anthropic-tool-error-hints +2).
Co-Authored-By: Claude Code <noreply@anthropic.com>
…ly stop after the batch (D2)
anthropic.js fed every `delta.function_call` frame into the accumulator at
index 0 with `+=` semantics, so a native client-tool call always died
(invalid JSON, or `unknown_tool: EditReadBashWrite` for two calls), the
platform's "Tool X does not exists." narration streamed to the client and
one suppressed retry fired. Both handlers now use D1's snapshot mode:
- Feed the RAW delta (before the normalizer) via pushNativeSnapshot with
phase + function_id; a role:function frame whose name is a client tool
(same predicate as the normalizer, now exported from chat-helpers as
createClientToolNamePredicate) closes its call; prose-resume and a
different call starting close the open call; status finished / non-null
finish_reason / EOF close everything as round_end.
- Emit on close (stream) / collect on close (non-stream); emitToolUse owns
hasEmittedToolCalls and the per-round cross-channel ledger (name +
canonical JSON) that drops a later duplicate with a warn. Non-stream
replaces the concat with the same ledger. Round tail drains the
round_end close, then finalize() once for OpenAI-shape tool_calls.
- Post-tool-use suppression: once a tool_use is on the wire, text/thinking
deltas of that round are account-only (round-scoped flag beside the
salvage-3 suppressAttemptOutput; reset in startAttempt).
- Early stop (D3 wiring): consumeUpstream forwards { shouldStop }; the
first prose-resume after every client call opened this round was closed
by its own named result frame and >=1 gated stops the upstream and
discards its content. No parity -> no early stop. Emitted arguments feed
the usage estimate; one log line per stop.
- Think-phase native frames (no function_id) feed thought_tool_call
evidence instead of the accumulator; terminal finish never fires
tool_error for native-origin errors (text-origin unchanged).
- One provenance warn per promoted call (name, phase, no function_id;
never the arguments). Rationale for honouring native calls after prose
recorded beside the text-channel position gate in tool-prompt.js.
Tests: tests/anthropic-native-toolcall.test.js (23, fixtures byte-faithful
to the 2026-09-01 capture: doubled final snapshot, trailing-period
not-exists frames, code_interpreter shape). 344 -> 367.
Co-Authored-By: Claude Code <noreply@anthropic.com>
…at paths (D5) openai-agent-runtime.js: collectOpenAIAgentAttempt feeds Qwen function_call frames through pushNativeSnapshot (phase/function_id preserved), closes calls on named role:function result frames / prose-resume / round-end, carries nativeToolCalls separately from text toolErrors and wires shouldStop for the batch-complete early stop. evaluateOpenAIAgentAttempt accepts a round with >=1 gated native call before the toolErrors veto and regardless of controlKind/visibleText; pre-call prose is forwarded as content, post-call narration dropped. chat.js legacy stream/non-stream: same snapshot feed; the stream path now recreates parser and accumulator before piping the compensation retry (was reused across attempts); tool_calls indices are owned by the caller so text-parser and native calls can never both be index 0. tests/agent-protocol.test.js: runAgentTurn native cases (headline, prose-before, platform code_interpreter round, empty-allowlist fail-closed) and the legacy chat.js index/retry pins. Suite 367 -> 378. Implementer agent was throttled before its commit; committed by the team lead after verifying the suite (378/378) and module load. Co-Authored-By: Claude Code <noreply@anthropic.com>
… batch parity Prod (2026-09-01 18:05:48Z) shows the model thinking for another 54s after the platform's "does not exists" injection before it emits any prose. With the tool_use blocks already on the wire, waiting for answer-phase prose to stop the upstream made the client wait out that thinking. Parity is unchanged (every client call closed by its named result frame, >=1 gated); only the trigger widens to any model-content frame that is not a role:function frame. A late parallel call still arrives as content-less function_call frames and cannot trip it. Both handlers. Tests: think-after-parity stops on the first think frame; think-before-parity does not. Co-Authored-By: Claude Code <noreply@anthropic.com>
…prose, scoped suppression, client-only result claim, think-stop parity Triaged review of fix/native-toolcall-promotion (F1-F5 code defects, P1-P9 missing pins, C1 fixture provenance). F1 openai-agent-runtime: a round with >=1 gated native call now DROPS the text-channel calls (one warn), restoring the pre-diff precedence `nativeToolCalls.length > 0 ? nativeToolCalls : text`. Merging both channels let a text `[TOOL CALL] rm -rf build` execute next to the native `git status`. The ledger still dedupes what remains. The "text Read + native Bash → [0,1]" pin is rewritten to the new contract. F2 openai-agent-runtime + chat.js: accepting on native calls skips the toolErrors veto and the orphan-residue check on purpose, but the visibleText of that round can carry a broken text `[TOOL CALL]`. evaluate now returns `suppressVisibleText` (text-origin toolErrors OR containsOrphanProtocolResidue); runOpenAIAgentTurn forwards it and prepareAgentOutput delivers content '' with the tool_calls intact. Clean pre-call prose still forwards (existing D5 e2e + non-stream twin). F3 anthropic.js stream: suppressPostToolUseOutput moves from emitToolUse (every tool_use) to drainPromotedNativeCalls (native promotions only), matching the non-stream `promotedNativeCalls` guard and main: prose/thinking after a TEXT-channel call reach the wire again. F4 tool-prompt closeByName: FIFO restricted to client candidates (`isClientCall && !resultSeen && name`), so a colliding platform call (client declares web_search) can no longer swallow the client's result frame and keep the batch from parity. Platform calls close by split/boundary/round_end, as the controllers drive them; the platform-own unit test is updated accordingly. F5 openai-agent-runtime: early stop fires on the first model-content frame (think OR answer) after parity, mirroring anthropic.js (58f56fd); closeOpen('boundary') stays gated on prose-resume. P1 S2 isolated / P2 S4 isolated / P6 S1 isolated split-term pins; the overlapping "native S2" test renamed. P3 function_id is the only discriminator (accumulator + stream controller twin with phase "answer"). P4 non-stream narration guard without result frames. P5 accept-before-veto ordering (platform unknown_tool + promoted Bash → no retry). P7 partially gated batch. P8 same-channel identical duplicate documented as a deliberate narrowing. P9 e2e invalid_arguments 502. C1 anthropic-native-toolcall fixtures: platform frames come from the 'natural' capture, not capture-foreign.txt — header and comments now cite both; CODE_INTERPRETER_SNAPSHOTS and SANDBOX_RESULT made byte-exact to frames Rfym21#2-Rfym21#12 (code_interpreter_info 'execute error' added); twins in tool-prompt/agent-protocol tests note their abbreviated snapshot lists. Tests: 380 → 396, all green. Co-Authored-By: Claude Code <noreply@anthropic.com>
… repair Text-channel parser (tool-prompt.js), both whole-text and streaming paths: - Replace the position gate with a semantic gate for any call that is not the first content of the answer: an explicit [TOOL CALL] trigger or an opener-less payload followed by a bracket closer is admitted after prose when salvage context exists (allowlist + toolSchemas) and the payload has the tool's required keys (gateAfterProsePayload). No schemas => today's suppression (fail closed). Candidates after prose are line-start only; the stream carries line-start state across chunk boundaries. - A first-position opener-less payload + closer that fails the gate is a hard error (errors + residue ledger) instead of visible prose, so it no longer poisons the rest of the batch. After-prose failures stay soft: visible, warning only, closer consumed, never a retry hint. - Unbalanced synthetic candidates: first-position salvage; debris cut at the earliest of next trigger / closer end / next candidate (no anchor => end of text). Salvage never reaches past another anchor. - escapeInnerQuotesInStrings: key/value-aware repair of unescaped inner quotes in string values (Qwen's most common JSON defect), last step of the repair chain; salvage repairs run independently, never chained. - One provenance log line per after-prose promotion (tool name only). Accepted risk recorded in the spec (bypass permissions: a schema-valid block quoted after prose executes; the position gate never covered the same block quoted first). Incident fixture (2026-09-02, five calls, only one executed before) now yields five tool_use on the wire; live probe against qwen3.8-max: narrated call -> text block + tool_use. Spec: _bmad-output/implementation-artifacts/spec-narrated-toolcall-and-inner-quote-repair.md Tests: 396 -> 446 passing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…fallback on both paths Clients send model ids the proxy does not own (Claude Code subagents send claude-opus-5 / haiku ids, OpenAI clients send gpt-*); Qwen answers "Model not found" and the proxy returns 500. One generic map, MODEL_MAP=alias=target,...,*=fallback, applied at the entry of both request builders before thinking / chat-type detection so a -thinking target switches thinking on. Exact entry wins (trailing [..] stripped first), known upstream ids pass through, else *, else the first upstream t2t model with a warn, else unchanged. Names that reach the fallback are recorded in a capped in-memory set (getUnmappedModels) for the dashboard that follows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tive aliases, bounded names, empty-list guard, target validation - isKnownUpstreamModel matches an upstream id or id+real suffix only (MODEL_SUFFIXES moved to src/utils/model-suffixes.js, shared with chat-helpers); no prefix heuristic - map keys lowercased and bracket-stripped at parse time; lookup lowercases the needle - incoming names sanitized (control chars stripped, 200-char cap) before record/log - warn decoupled from the record cap; one warn when the cap fills - empty upstream list: no "*"/default applied, one warn per process; unchanged returns the stripped name - warn once per unknown MODEL_MAP target; blank model routes through "*"/default - no info line for bracket-only strips of known ids - docs: endpoints covered, response model echo, unlisted aliases, per-process record, client-side ANTHROPIC_DEFAULT_*_MODEL note; MODEL_MAP in READMEs and compose samples - tests: 16 unit + 3 integration (processRequestBody, handleAnthropicMessages) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…seen-unassigned chips, persisted settings - Settings view: "Model mapping" card first — alias → Qwen target rows (targets from chat-capable upstream models incl. -thinking variants), fallback for everything else, chips for names that fell to the fallback, env/saved/unsaved badges, restore-env, client pre-checks, dirty guard, none-mode hint; en/zh/ru copy - GET /api/settings: modelMap, modelMapEnv, unmappedModels, modelMapTargets, dataSaveMode - POST /api/setModelMap: validates every target against the upstream list (one forced refresh on a stale cache), row/length caps, 400 with per-row errors, reset to env, applies at runtime and persists; prunes saved aliases from the unmapped record - applyPersistedSettings extracted to src/utils/persisted-settings.js; dashboard-saved map overrides MODEL_MAP env at boot (logged) - redis setSettings: read-merge-write instead of overwriting the whole settings JSON (a partial save used to wipe apiKeys and retry config) - tests: settings route handlers, persisted settings, redis merge with a fake client, buildModelMap/forgetUnmapped cases (490 total) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
_bmad-output/ holds local planning artifacts from the bmad tooling; it was committed by mistake in Rfym21#164 and does not belong in the project tree. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
e60468e to
f78b3ce
Compare
…of width Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
After a narrated [TOOL CALL] Qwen keeps generating: it repeats the same call hundreds of times or hallucinates a whole agentic session (prod 2026-09-03..06: 6-60 minute streams, single responses with 245/437/531 back-to-back calls that Claude Code executed under bypass permissions). Both Anthropic handlers only cut the upstream early for native function_call batches. Once a text-channel call has been admitted in an earlier upstream delta, the turn now ends on the first runaway signal — duplicate call, rejected/malformed call, non-whitespace prose or thinking — or on the N-th admitted call (AGENT_TURN_MAX_TOOL_CALLS, default 24, clamped 4..256). The upstream is stopped, the admitted calls are delivered with stop_reason=tool_use, and nothing after the tool_use block reaches the wire. Streaming and non-streaming handlers; the non-stream twin settles cut rounds from its incremental parser instead of re-parsing the truncated buffer. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…load and its closer Incident 2026-09-06 (Claude Code via /v1/messages): the model wrote one extra '}' after a balanced tool-call payload. consumeTrailingCloser saw a non-closer char and resolved the call without a closer, so the '}' leaked as a text delta ahead of the tool_use and the following [END TOOL CALL] leaked as an orphan. No WARN was logged. skipClosingDebris now skips whitespace plus a bounded (TRAILING_DEBRIS_MAX=8, all-or-nothing) run of '}' / ']' before looking for the closer, in both consumeTrailingCloser and consumeMandatoryBracketCloser. The closer itself stays mandatory on the synthetic path. Covered at chunk sizes 1/7/40/whole, whole-text parity, and an end-to-end /v1/messages wire replay.
…/completions Move the text-channel runaway guard (duplicate / rejected / prose+think / cap) and its ledger from anthropic.js into src/utils/agent-turn.js (log tag is now a parameter) and run it on the OpenAI path: collectOpenAIAgentAttempt feeds an unconditional per-attempt detection parser, cuts with stopRequested, skips text parser flushes, and settles the cut round from admitted calls with errors: [], so a runaway turn resolves as HTTP 200 finish_reason=tool_calls (<= cap) instead of retries/422. Cap is enforced while draining the triggering push. Build req.tool_schemas in chat-middleware (Object.create(null), duplicate names fail-closed, tools without an object `parameters` skipped) and thread it to the answer-phase stream parsers, the detection parser, the native accumulator and the answer parseToolCallsFromText — not the think-phase parsers, matching the Anthropic path — so inner-quote repair and after-prose acceptance work here too. Anthropic controller: pure refactor (import + tag). tool-prompt.js: comments only. Tests: new tests/openai-agent-turn-cutoff.test.js (38) incl. incident replay at 9-byte chunking and e2e wiring through handleChatCompletion. npm test 594/594.
…at triggered a non-cap cut After a cut by a non-cap rule (duplicate/think/after-prose), inspectCall stopped returning rules — including 'cap' — so the remaining completed calls in that same push were all admitted and emitted. One delta carrying 40 more calls delivered 41 against a cap of 24. On the Anthropic streaming branch emitToolUse writes tool_use blocks to the wire immediately, so the excess was unrecoverable. Guard moved into the shared createTextChannelRunawayGuard#inspectCall so all three callers (Anthropic stream, Anthropic non-stream, OpenAI runtime) get it once; the OpenAI caller-side break from f49c8aa is removed as redundant. Gated on cutRule and placed before the ledger, so the no-cut path is unchanged. Tests: tests/anthropic-cap-drain.test.js (stream + non-stream twin). Removing the guard fails 3 rows (both Anthropic + the OpenAI drain row). Suite 596/596.
…ed chat_id
Incident qwen-next 2026-09-06 20:29 (strict gate, no AGENT_TURN_ALLOW_PROSE_WITH_TOOLS):
the model narrated before its call, the runaway guard cut on the duplicate, and
evaluateOpenAIAgentAttempt rejected the round as prose+tools. The retry was posted to
the same chat_id whose aborted generation was still running upstream -> CHAT_IN_PROGRESS
-> 502. This contradicted settledTextRound's promise ("a cut round with admitted calls
is ALWAYS delivered") and the Anthropic path (decideRetryReason returns as soon as calls
were emitted).
- collect: expose textChannelCut / upstreamStopped on the attempt.
- evaluate: a cut round with admitted calls is accepted before the toolErrors veto and
the prose-with-tools rule; under the strict flag the pre-cut prose is suppressed on the
wire instead of rejecting the round. Prod flag behaviour unchanged.
- retry: after a cut/stopped upstream, open a fresh chat (chatId/parentId null) instead of
reusing the busy one (defense in depth; no longer reachable via the guard rules).
- rejection log now carries detail (tool_errors | prose_with_tools) — the incident was
indistinguishable in logs without it.
- tests: P7(b) inverted (cut round delivers under strict gate), +4 wire/runtime cases,
deepEqual expectation updated for `detail`.
18248e6 — cut round is always delivered; retry never reuses the aborted
|
| image | result | server log |
|---|---|---|
pre-fix (7ca6fee build) |
2/2 → HTTP 502 {"code":"CHAT_IN_PROGRESS"} |
失控信号 (cap) 24/24 → attempt 1/3 被回合门禁拒绝 (invalid_tool_call) → CHAT_IN_PROGRESS |
this commit (18248e6) |
3/3 (2 non-stream + 1 stream) → HTTP 200, finish_reason=tool_calls, 24 tool_calls, content="" |
失控信号 (cap) 24/24 → round accepted, content emptied, tool_calls delivered; no rejection, no retry |
Non-cut prose+tools rounds under the strict gate still reject and retry on a fresh chat (verified: attempts 1→2→3, no CHAT_IN_PROGRESS).
Summary
Qwen's web platform has native function calling. When the model decides to call one of the client's tools that way (a rare but real sampling drift in long agentic sessions — Claude Code, openclaw), upstream streams the call as structured
delta.function_callframes with complete, valid arguments, then looks the name up in its own registry, injectsrole:function "Tool Bash does not exists."into the model's context, and the model narrates "tools are unavailable" for 30–60 s. Users see "Tool server down — Bash, Read, Write, Edit all return does not exists" and the agent declares work done while believing it has no tools.The gateway was throwing the good calls away: every feed site pushed each
function_callframe intocreateNativeToolCallAccumulatorat hard-codedindex: 0, which concatenates differing names (unknown_tool: EditReadBashWrite— the log fingerprint) and doesarguments +=on what are actually cumulative snapshots. A single native call died asinvalid_arguments, two died asunknown_tool, on every path. Retry hints cannot fix it: the retry goes native again (verified).This PR promotes native
function_callframes to real tool calls and cuts the upstream once the batch is complete.Wire format (captured live 2026-09-01,
qwen3.8-max)Plain-text
<tool_call>/<function=X>is not intercepted — the interceptor is the serving stack's native tool parser, not a text matcher.Changes
tool-prompt.jsaccumulator — newpushNativeSnapshot({name, arguments, phase, functionId}): identity-keyed, arguments replaced by the latest snapshot; split predicate with merge default (S1 function_id differs · S2 name differs · S3 open snapshot complete JSON and incoming not an extension · S4''while open non-empty · S5 reopen after close, byte-identical reopen = duplicate dropped); judge-on-close withunknown_tool/invalid_arguments/truncated_native_call/missing_tool_name/schema_mismatch;takeCompleted()idempotent drain;batchState()for the stop tally.push()(OpenAIdelta.tool_calls, delta-append) is byte-identical. Structural classification: client candidate iff nofunction_idand answer phase — platform-own calls keep today'sunknown_tool → tool_errorretry (a client may declare a tool literally namedweb_search). Fail-closed on an empty allowlist; plain-object check; advisory schema check (reject only on missingrequired).sse.js—consumeSSEStream(stream, onFrame, { shouldStop }): the consumerbreaks its ownfor await(clean destroy, noERR_STREAM_PREMATURE_CLOSE), returns{…, completed: true, stopped: true}—completedkeeps meaning "no transport failure" so the non-stream 502 guards stay quiet.anthropic.js(stream + non-stream) — feed the raw delta before the normalizer; close on the namedrole:functionresult frame / next call / prose-resume / round-end; emittool_useon close (idempotent,hasEmittedToolCallsowned byemitToolUse); early stop once every client call of the round is closed by its own result frame, ≥1 gated, and the first subsequent model-content frame (think or answer) arrives — prod showed 54 s of post-injection thinking; post-promotion narration suppressed; cross-channel(name, canonical args)ledger; native calls honoured regardless of prior prose (the text-channel first-content gate is unchanged — rationale recorded next to it); think-phase native frames feed the existingthought_tool_callretry; usage fallback fed with the emitted arguments.openai-agent-runtime.js/chat.js— same promotion; a round with ≥1 gated native call is accepted before thetoolErrorsveto and regardless ofcontrolKind/visibleText(without flippingAGENT_TURN_ALLOW_PROSE_WITH_TOOLS); native supersedes text-channel calls in the same round (restores the pre-existing precedence); tainted prose (text-origin tool errors / orphan residue) is not forwarded ascontent; legacy chat stream recreates parser+accumulator before the compensation retry; caller-owned monotonictool_callsindex.describeToolErrors/buildToolErrorRetryHintlearn the new error types (they printedunspecifiedbefore).Tests
325 → 556, all green (396 after the native promotion, 446 after the narrated-call gate, 490 after the model map + dashboard, 556 after the runaway cut + stray-brace fix). New
tests/anthropic-native-toolcall.test.js(fixtures byte-faithful to the two live captures), snapshot-mode twins intool-prompt.test.js, early-stop siblings insse.test.js, runtime/legacy cases inagent-protocol.test.js. Every behavioural bullet was written failing-first; 28 mutants (each split rule, the discriminator, replace-vs-append, early stop, parity, suppression, ledger, accept-before-veto, index unification, …) are killed by named tests. Existing pins (protocol-recovery caps, Defect A, web_search, salvage-3) untouched and green.Live verification
Real handler against real upstream native frames (gateway protocol prompt deliberately omitted so the model takes the native path):
end_turn, 0 tool_use, 9.9 stool_use, 0 retries, 0 narration, early stop, 3.9 sunknown_tool: SendMessageBash, 14.4 stool_use, 200, 3.8 sinvalid_tool_call; dropped: SendMessage, Bashfinish_reason tool_calls, 2 calls, 2.9 sAlso replayed a real 241 KiB Claude Code history ×6 through
/v1/messages— no regressions on the text channel.Deferred (documented)
chat.jslegacyhas_toolsstream branch (unreachable in prod — no caller setsstrict_agent_turn) still ships narration beside promoted calls.invalid_tool_callhint rather thanthought_tool_call(needs a new gate reason).Also in this PR (later commits on the same branch)
Narrated / opener-less text-channel tool calls reach the client (
bde4a5a)Incident (2026-09-02, Claude Code via
/v1/messages): five[TOOL CALL]blocks after a paragraph of narration, only one executed; the rest leaked as text or asOOL CALL]fragments. The position gate intool-prompt.js(only the first content of the answer may be a call) dropped every call that followed prose.[TOOL CALL]trigger, or an opener-less payload followed by a bracket closer, is promoted after prose when the tool is in the allowlist and the payload carries the tool'srequiredkeys (gateAfterProsePayload). No schemas → today's suppression (fail closed). Line-start only; the stream carries line-start state across chunks.escapeInnerQuotesInStrings: key/value-aware repair of unescaped inner quotes in JSON string values (Qwen's most common defect), last step of the repair chain.tool_use; real Claude Code run onqwen3.8-max-thinking→ 4/4 tool calls, 0 leaked protocol text.MODEL_MAP: incoming model-name mapping with fallback (aa25073,81b8cec).env.exampledocumentedCLAUDE_MODEL_MAPbut nothing implemented it; Claude Code subagents sendclaude-opus-5(and haiku ids for background calls), OpenAI clients sendgpt-*, and upstream answered "Model not found" → 500, killing the subagent.src/utils/model-map.js:MODEL_MAP=alias=target,...,*=fallback. Exact entry (case-insensitive, trailing[..]stripped) → known upstream id passes through (id or id + real suffix, no prefix heuristics) →*→ first upstream t2t model with a warn → unchanged. Applied at the entry of both request builders (/v1/messages,/v1/chat/completions) before thinking/chat-type detection, so a-thinkingtarget switches thinking on.processRequestBody,handleAnthropicMessages). Live:claude-opus-5→ 200qwen3.8-max,gpt-4o→ 200, Claude Code spawning anopussubagent completes with 0 "Model not found".Dashboard: Model mapping screen (
80d91a7)Settings → first card:
alias → Qwen targetrows (targets = chat-capable upstream models incl.-thinkingvariants, independent ofSIMPLE_MODEL_MAP), an "everything else" fallback, chips for names seen at the fallback (click prefills a row),env/saved/unsavedbadges, restore-env, client pre-checks, en/zh/ru.GET /api/settings→modelMap,modelMapEnv,unmappedModels,modelMapTargets,dataSaveMode.POST /api/setModelMapvalidates every target against the upstream list (one forced refresh on a stale cache), caps rows/lengths, returns 400 with per-row errors, applies at runtime and persists;{reset:true}restores the env map. A dashboard-saved map overridesMODEL_MAPat boot (logged).setSettingsoverwrote the whole settings JSON with the partial being saved, so saving retry config or a key wiped the others (apiKeysincluded). Now read-merge-write.router.stack, persisted-settings restore, redis merge with a fake client. Live (redis): save → next request maps[exact]without restart;docker restartkeeps the map andchatRetryCount.Runaway text-channel tool calls: cut the upstream (
619aebf)Prod 2026-09-03..06 (Claude Code via
/v1/messages, bypass permissions): after a narrated[TOOL CALL]Qwen keeps generating — the same call repeated hundreds of times, or a whole hallucinated agentic session. Single responses carried 245 / 437 / 531 back-to-back calls in 6–60 minute streams, and Claude Code executed them. Both Anthropic handlers only cut the upstream early for nativefunction_callbatches (above); a text-channel round ran until upstream stopped on its own.(name, canonical args)ledger), rejected/malformed call, non-whitespace prose or thinking after the call — or on the N-th admitted call (AGENT_TURN_MAX_TOOL_CALLS, default 24, clamped 4..256; documented in.env.exampleand the three READMEs).stop_reason: tool_use, and nothing after thetool_useblock reaches the wire. Streaming and non-streaming handlers; the non-stream twin settles cut rounds from its incremental parser instead of re-parsing the truncated buffer.tests/anthropic-narrated-toolcall.test.js(+680 lines: each runaway signal, the cap and its clamp, stream/non-stream parity, nothing-after-tool_useon the wire) plus early-stop adjustments intests/anthropic-native-toolcall.test.js.Stray closing brace after a balanced payload (
b15464e)Incident 2026-09-06 (Claude Code via
/v1/messages): the model wrote one extra}after a balanced tool-call payload.consumeTrailingClosersaw a non-closer char and resolved the call without a closer, so the}leaked as a text delta ahead of thetool_useand the following[END TOOL CALL]leaked as an orphan — with no WARN.skipClosingDebrisnow skips whitespace plus a bounded (TRAILING_DEBRIS_MAX = 8, all-or-nothing) run of}/]before looking for the closer, in bothconsumeTrailingCloserandconsumeMandatoryBracketCloser. The closer itself stays mandatory on the synthetic path.tests/anthropic-extra-brace-closer.test.js— chunk sizes 1 / 7 / 40 / whole, whole-text parity, and an end-to-end/v1/messageswire replay of the incident.Both fixes run on the author's staging instance (Claude Code sessions) since 2026-09-06.
Dashboard (
cb30551)Checkbox hover fill animates with
transforminstead ofwidth.This branch also deletes
_bmad-output/implementation-artifacts/spec-anthropic-agent-loop-parity.md, a local planning artifact that slipped into #164 by mistake; the branch history was rewritten so no other_bmad-output/files appear in it.OpenAI
/v1/chat/completionsparity: runaway cut, tool schemas, shared cap guard (f49c8aa,7ca6fee)Everything above only protected
/v1/messages. The OpenAI path had none of it: a duplicated / narrated text-channel tool call kept streaming until the 24-call cap or the retry budget, tool payloads were never checked against the request's schemas, and a cut that landed mid-delta still drained the rest of that delta past the cap.createTextChannelRunawayGuard,createAgentControlStreamParser,createNativeToolCallAccumulator,parseToolCallsFromTextand the cut/ledger helpers moved out ofanthropic.jsintosrc/utils/agent-turn.js(−157 lines there, behaviour byte-identical — the existing Anthropic suites pass untouched).openai-agent-runtime.jsnow uses the same guard in both stream and non-stream, tagged[AGENT].processRequestBodycarriestools[].function.parametersthrough astoolSchemas; the semantic gate rejects payloads that missrequiredkeys instead of promoting them. Tools declared without an objectparametersare still gated (no silent bypass).errors: [], so the evaluator no longer rejects it and re-runs the runaway (that loop was the 502).7ca6fee). After a non-cap cutinspectCallstopped returning'cap', so one delta carrying 40 more completed calls delivered 41 against a cap of 24 — on the Anthropic streaming branch thosetool_useblocks were already on the wire. Guard is now one line in the sharedinspectCall, gated oncutRule, covering all three callers.AGENT_TURN_MAX_TOOL_CALLS(default 24) documented in.env.example;AGENT_TURN_ALLOW_PROSE_WITH_TOOLS(defaultfalse) keeps the OpenAI path strict about prose + calls in one turn — Anthropic delivers those; this asymmetry is deliberate and configurable.tests/openai-agent-turn-cutoff.test.js(38 rows: every cut rule, stream/non-stream twins, evaluator settle, cap-drain, schema gate, unschema'd tool, wiring test onhandleChatCompletion) andtests/anthropic-cap-drain.test.js(2). Suite 596/596. Each guard was mutation-checked: removing it fails at least one row.qwen3.8-max-thinking):/v1/chat/completionsstream and non-stream with a "same Bash call three times" prompt → server logs[AGENT] … 失控信号 (duplicate), exactly 1tool_calldelivered,finish_reason: tool_calls, no residue, no retry; three distinct calls → 3/3 in both modes;/v1/messagessame prompt →[ANTHROPIC] … (duplicate),stop_reason: tool_use; a real Claude Code session (Write → Read → Bash, 7 turns) completed clean.