Skip to content

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

Merged
Rfym21 merged 16 commits into
Rfym21:mainfrom
maxff77:fix/native-toolcall-promotion
Sep 7, 2026

Conversation

@maxff77

@maxff77 maxff77 commented Sep 1, 2026

Copy link
Copy Markdown

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_call frames with complete, valid arguments, then looks the name up in its own registry, injects role: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_call frame into createNativeToolCallAccumulator at hard-coded index: 0, which concatenates differing names (unknown_tool: EditReadBashWrite — the log fingerprint) and does arguments += on what are actually cumulative snapshots. A single native call died as invalid_arguments, two died as unknown_tool, on every path. Retry hints cannot fix it: the retry goes native again (verified).

This PR promotes native function_call frames to real tool calls and cuts the upstream once the batch is complete.

Wire format (captured live 2026-09-01, qwen3.8-max)

// call frames, repeated while streaming — arguments is a CUMULATIVE SNAPSHOT, final snapshot sent twice,
// client tools carry no function_id; parallel calls arrive sequentially (all SendMessage, then all Bash)
{"role":"assistant","content":"","phase":"answer","status":"typing",
 "function_call":{"name":"Bash","arguments":"{\"command\": \"git status"},"extra":{"display_position":"answer"}}
// platform-own tools differ structurally: phase = tool name + function_id
{"role":"assistant","content":"","phase":"code_interpreter","status":"typing",
 "function_call":{"name":"code_interpreter","arguments":"{\"code\": \"ls"},"function_id":"round_0_call_45542fe5…"}
// registry lookup, injected into the model's context
{"role":"function","content":"Tool Bash does not exists.","phase":"answer","status":"typing","name":"Bash"}

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.js accumulator — new pushNativeSnapshot({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 with unknown_tool / invalid_arguments / truncated_native_call / missing_tool_name / schema_mismatch; takeCompleted() idempotent drain; batchState() for the stop tally. push() (OpenAI delta.tool_calls, delta-append) is byte-identical. Structural classification: client candidate iff no function_id and answer phase — platform-own calls keep today's unknown_tool → tool_error retry (a client may declare a tool literally named web_search). Fail-closed on an empty allowlist; plain-object check; advisory schema check (reject only on missing required).
  • sse.jsconsumeSSEStream(stream, onFrame, { shouldStop }): the consumer breaks its own for await (clean destroy, no ERR_STREAM_PREMATURE_CLOSE), returns {…, completed: true, stopped: true}completed keeps 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 named role:function result frame / next call / prose-resume / round-end; emit tool_use on close (idempotent, hasEmittedToolCalls owned by emitToolUse); 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 existing thought_tool_call retry; 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 the toolErrors veto and regardless of controlKind/visibleText (without flipping AGENT_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 as content; legacy chat stream recreates parser+accumulator before the compensation retry; caller-owned monotonic tool_calls index.
  • describeToolErrors / buildToolErrorRetryHint learn the new error types (they printed unspecified before).

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 in tool-prompt.test.js, early-stop siblings in sse.test.js, runtime/legacy cases in agent-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):

path before (main) after
Anthropic stream narration + end_turn, 0 tool_use, 9.9 s 2 tool_use, 0 retries, 0 narration, early stop, 3.9 s
Anthropic non-stream 502 unknown_tool: SendMessageBash, 14.4 s 2 tool_use, 200, 3.8 s
OpenAI runtime invalid_tool_call; dropped: SendMessage, Bash finish_reason tool_calls, 2 calls, 2.9 s

Also replayed a real 241 KiB Claude Code history ×6 through /v1/messages — no regressions on the text channel.

Deferred (documented)

  • chat.js legacy has_tools stream branch (unreachable in prod — no caller sets strict_agent_turn) still ships narration beside promoted calls.
  • OpenAI runtime gives think-phase native frames the generic invalid_tool_call hint rather than thought_tool_call (needs a new gate reason).
  • Same-channel identical duplicate in one round collapses to one call (deliberate, errs safe; pinned).

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 as OOL CALL] fragments. The position gate in tool-prompt.js (only the first content of the answer may be a call) dropped every call that followed prose.

  • Position gate replaced by a semantic gate for any call that is not the first content: an explicit [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's required keys (gateAfterProsePayload). No schemas → today's suppression (fail closed). Line-start only; the stream carries line-start state across chunks.
  • A first-position payload that fails the gate is a hard error (retry) instead of visible prose; after-prose failures stay soft (visible, warning, never a retry hint).
  • escapeInnerQuotesInStrings: key/value-aware repair of unescaped inner quotes in JSON string values (Qwen's most common defect), last step of the repair chain.
  • Accepted risk recorded in the spec: with bypass permissions, a schema-valid block quoted after prose executes (the position gate never covered the same block quoted first).
  • Live: the incident fixture yields five tool_use; real Claude Code run on qwen3.8-max-thinking → 4/4 tool calls, 0 leaked protocol text.

MODEL_MAP: incoming model-name mapping with fallback (aa25073, 81b8cec)

.env.example documented CLAUDE_MODEL_MAP but nothing implemented it; Claude Code subagents send claude-opus-5 (and haiku ids for background calls), OpenAI clients send gpt-*, 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 -thinking target switches thinking on.
  • Names that fell to the fallback are recorded (sanitized, capped) with one warn per name; unknown map targets warn once; empty upstream list never reroutes a Qwen id.
  • Tests: 16 unit + 3 integration (processRequestBody, handleAnthropicMessages). Live: claude-opus-5 → 200 qwen3.8-max, gpt-4o → 200, Claude Code spawning an opus subagent completes with 0 "Model not found".

Dashboard: Model mapping screen (80d91a7)

Settings → first card: alias → Qwen target rows (targets = chat-capable upstream models incl. -thinking variants, independent of SIMPLE_MODEL_MAP), an "everything else" fallback, chips for names seen at the fallback (click prefills a row), env / saved / unsaved badges, restore-env, client pre-checks, en/zh/ru.

  • GET /api/settingsmodelMap, modelMapEnv, unmappedModels, modelMapTargets, dataSaveMode. POST /api/setModelMap validates 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 overrides MODEL_MAP at boot (logged).
  • Pre-existing bug fixed: in redis mode setSettings overwrote the whole settings JSON with the partial being saved, so saving retry config or a key wiped the others (apiKeys included). Now read-merge-write.
  • Tests: route handlers via router.stack, persisted-settings restore, redis merge with a fake client. Live (redis): save → next request maps [exact] without restart; docker restart keeps the map and chatRetryCount.

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 native function_call batches (above); a text-channel round ran until upstream stopped on its own.

  • Once a text-channel call has been admitted in an earlier upstream delta, the turn ends on the first runaway signal — duplicate call (same (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.example and the three READMEs).
  • 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.
  • Tests: tests/anthropic-narrated-toolcall.test.js (+680 lines: each runaway signal, the cap and its clamp, stream/non-stream parity, nothing-after-tool_use on the wire) plus early-stop adjustments in tests/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. 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 — with no WARN.

  • 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.
  • Tests: tests/anthropic-extra-brace-closer.test.js — chunk sizes 1 / 7 / 40 / whole, whole-text parity, and an end-to-end /v1/messages wire 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 transform instead of width.

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/completions parity: 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.

  • Extraction, not duplication. createTextChannelRunawayGuard, createAgentControlStreamParser, createNativeToolCallAccumulator, parseToolCallsFromText and the cut/ledger helpers moved out of anthropic.js into src/utils/agent-turn.js (−157 lines there, behaviour byte-identical — the existing Anthropic suites pass untouched). openai-agent-runtime.js now uses the same guard in both stream and non-stream, tagged [AGENT].
  • Tool schemas reach the OpenAI parsers. processRequestBody carries tools[].function.parameters through as toolSchemas; the semantic gate rejects payloads that miss required keys instead of promoting them. Tools declared without an object parameters are still gated (no silent bypass).
  • A cut settles cleanly. After a cut the attempt reports errors: [], so the evaluator no longer rejects it and re-runs the runaway (that loop was the 502).
  • Cap holds while draining the triggering push (7ca6fee). After a non-cap cut inspectCall stopped returning 'cap', so one delta carrying 40 more completed calls delivered 41 against a cap of 24 — on the Anthropic streaming branch those tool_use blocks were already on the wire. Guard is now one line in the shared inspectCall, gated on cutRule, covering all three callers.
  • Config: AGENT_TURN_MAX_TOOL_CALLS (default 24) documented in .env.example; AGENT_TURN_ALLOW_PROSE_WITH_TOOLS (default false) keeps the OpenAI path strict about prose + calls in one turn — Anthropic delivers those; this asymmetry is deliberate and configurable.
  • Tests: 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 on handleChatCompletion) and tests/anthropic-cap-drain.test.js (2). Suite 596/596. Each guard was mutation-checked: removing it fails at least one row.
  • Live on the author's staging (2026-09-06, qwen3.8-max-thinking): /v1/chat/completions stream and non-stream with a "same Bash call three times" prompt → server logs [AGENT] … 失控信号 (duplicate), exactly 1 tool_call delivered, finish_reason: tool_calls, no residue, no retry; three distinct calls → 3/3 in both modes; /v1/messages same prompt → [ANTHROPIC] … (duplicate), stop_reason: tool_use; a real Claude Code session (Write → Read → Bash, 7 turns) completed clean.

PEDRO LOBATO CARCAMO and others added 9 commits September 1, 2026 13:36
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>
@maxff77 maxff77 changed the title fix(agent): promote Qwen's native function_call frames to tool calls; early stop after the batch fix(agent): native and narrated tool calls reach the client; MODEL_MAP alias mapping with a dashboard editor Sep 3, 2026
_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>
@maxff77
maxff77 force-pushed the fix/native-toolcall-promotion branch from e60468e to f78b3ce Compare September 3, 2026 05:01
PEDRO LOBATO CARCAMO and others added 3 commits September 5, 2026 21:13
…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.
@maxff77 maxff77 changed the title fix(agent): native and narrated tool calls reach the client; MODEL_MAP alias mapping with a dashboard editor fix(agent): native and narrated tool calls reach the client, runaway text-channel calls are cut; MODEL_MAP alias mapping with a dashboard editor Sep 6, 2026
PEDRO LOBATO CARCAMO added 2 commits September 6, 2026 19:14
…/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.
@maxff77 maxff77 changed the title fix(agent): native and narrated tool calls reach the client, runaway text-channel calls are cut; MODEL_MAP alias mapping with a dashboard editor 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 Sep 7, 2026
…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`.
@maxff77

maxff77 commented Sep 7, 2026

Copy link
Copy Markdown
Author

18248e6 — cut round is always delivered; retry never reuses the aborted chat_id

Incident (qwen-next staging, strict gate = no AGENT_TURN_ALLOW_PROSE_WITH_TOOLS): the model narrated before its tool calls, the text-channel runaway guard cut the upstream, and evaluateOpenAIAgentAttempt still rejected the round as prose+tools. The retry was posted on the same chat_id whose aborted generation was still running upstream → CHAT_IN_PROGRESS → HTTP 502. That contradicted settledTextRound's contract ("a cut round with admitted calls is ALWAYS delivered") and the Anthropic path, which returns as soon as calls were emitted.

Change (src/utils/openai-agent-runtime.js):

  • collect exposes textChannelCut / upstreamStopped on the attempt;
  • evaluate accepts a cut round with admitted calls 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. Behaviour with the prod flags is unchanged;
  • retry after a cut/stopped upstream opens a fresh chat (chatId/parentId = null) instead of reusing the busy one (defense in depth — no longer reachable through the guard rules);
  • the rejection log now names the reason: invalid_tool_call:tool_errors vs invalid_tool_call:prose_with_tools (the incident was indistinguishable in logs without it).

Tests: P7(b) inverted (a cut round delivers under the strict gate), +4 wire/runtime cases; node --test tests/*.test.js → 600/600.

Live A/B on qwen-next (real Qwen upstream, strict gate), same probe on both images — 26 echo N Bash calls requested with narration forced before the first call, so the guard's cap rule cuts at 24/24 with pre-cut prose:

image result server log
pre-fix (7ca6fee build) 2/2 → HTTP 502 {"code":"CHAT_IN_PROGRESS"} 失控信号 (cap) 24/24attempt 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).

@Rfym21
Rfym21 merged commit fa67e71 into Rfym21:main Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants