unsloth/studio/backend/tests/test_passthrough_healing.py
Daniel Han f109e7f0e6
Studio: parse Mistral [TOOL_CALLS] and rehearsal tool-call shapes (#5704)
* Studio: parse Mistral [TOOL_CALLS] and rehearsal tool-call shapes

Extends the rescue parsers in core/tool_healing.py and
core/inference/tool_call_parser.py to recognise two extra serialisations
local models commonly emit when bypassing native function calling:

* [TOOL_CALLS]name{json_args} (Devstral-Small-2, Mistral-Small-3.x).
* name[ARGS]{json_args} (reasoning-model rehearsal).

Both extractors use a brace-balance scan that honours escapes and
quoted strings so nested JSON args stay intact.

Also pre-strips <think>...</think> and [THINK]...[/THINK] blocks before
matching so calls emitted after a reasoning preamble are recognised
regardless of position.

Streaming gates (TOOL_XML_SIGNALS, llama_cpp.py _TOOL_XML_SIGNALS) and
the SSE strip regex (routes/inference.py _TOOL_XML_RE) gain the new
sentinels so the parser is actually invoked and the raw markup never
leaks to the UI.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Strip unclosed think blocks and catch rehearsal [ARGS] mid-buffer

The pre-existing ``_THINK_TAG_RE`` only matched closed thinking
blocks (``<think>...</think>`` or ``[THINK]...[/THINK]``). During
streaming the model is still inside the open block when the parser
runs, so any tool-shaped markup the model is REHEARSING inside that
block survived the strip and could be executed as a real call.
Switch both copies of the regex (parser + healing) to accept the
trailing block being terminated by end-of-string in addition to
the explicit closer.

The ``_TOOL_XML_SIGNALS`` list on the llama_cpp streaming buffer
included ``[ARGS]`` to catch rehearsal syntax, but the gate used a
``startswith`` check against the buffer head -- rehearsal is shaped
``name[ARGS]{json}``, so the buffer never STARTS with ``[ARGS]``
and the signal had no effect. Add a substring fallback for the
bracket-style signals so the BUFFERING window can still divert the
stream into DRAINING when rehearsal markup arrives mid-buffer.

Adds three regression tests covering rehearsal inside unclosed
``<think>`` / ``[THINK]`` blocks (must yield no calls) and the
positive case after a closed think block (still parsed).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: harden bracket-tag tool-call parsing and streaming strip

Address review findings on the Mistral [TOOL_CALLS] / rehearsal [ARGS] paths:

- Accept hyphenated tool names in the bracket parsers and strip patterns.
  _MISTRAL_BRACKET_RE and _REHEARSAL_RE used \w+, which dropped or truncated
  MCP function names containing dashes (mcp__srv__list-issues). Use [\w-]+ to
  match the XML and Gemma parsers.
- Strip a partial bracket marker streamed before its opening brace. The
  trailing-unclosed patterns required the {, so a [TOOL_CALLS]web_search or
  python[ARGS] split across deltas leaked the raw marker to the UI. Match the
  bare marker to end-of-text, mirroring how the bare open tags are stripped.
  Closed pairs are unchanged so in-progress markup stays buffered until parsed.
- Strip a truncated bracket tail in the route-level display regex. _TOOL_XML_RE
  required a balanced JSON object; a tool call truncated by EOS now strips up
  to \Z, like the orphan-opening XML shapes. Complete calls still strip only
  their balanced JSON so following prose survives.

Add regression tests for hyphenated names, the streaming partial-marker strip,
and the unclosed-tail route strip.

* Studio: preserve XML parameter indentation in tool_healing

The chat template emits <parameter=k>\nVALUE\n</parameter>; the parameter-start
regex consumed the wrapping newline AND the value's first-line indentation via a
trailing \s*, then str.strip() removed the rest, corrupting code/diff arguments.
Narrow the trailing class to horizontal whitespace and trim exactly one wrapping
newline (_trim_param_value), preserving indentation. Matches SGLang's qwen3_coder
detector and the same fix on the multi-format parser. Add a regression test.

* Studio: tighten Mistral/rehearsal tool-call comments

Compress the comments in the Mistral [TOOL_CALLS] / rehearsal [ARGS] healing shim
and its callers to one or two lines, keeping the bracket-tag stripping rationale,
the thinking-block handling note, and the forge attribution intact.

Comment-only: no code or behavior change (verified with comment_tools.py check
--strip-docstrings; tests green).

* Studio: fix think-strip arg corruption and nested bracket-JSON strip

Review follow-up for the Mistral/rehearsal healing shim:

- The <think>/[THINK] strip ran unconditionally over the whole content before
  parsing, so a real tool argument that legitimately contained a <think> /
  [THINK] literal was silently corrupted. Don't delete the blocks: compute the
  reasoning-block spans and skip any tool-call candidate that STARTS inside one,
  across all parse paths (JSON, Gemma, XML, bracket, rehearsal). A rehearsed call
  inside reasoning is still ignored; a real call after </think> still parses.
- The bracket-tag display strip used a fixed one-level-nesting regex, so a call
  with two-level-nested JSON args either leaked raw markup or, in final mode, let
  the catch-all eat the trailing prose. Add a balanced-brace
  _strip_bracket_tag_calls pass (any nesting depth) used by strip_tool_call_markup
  and the route display strip.

Add regressions: <think>/[THINK] literal inside a real argument, rehearsal-inside-
think with a real call after, and two-level-nested bracket/rehearsal strip keeping
trailing prose.

* Studio: correct think-block comments to match span-skip behavior

The think-strip fix replaced the unconditional think-block strip with a
span-skip (the block is kept and any tool-call candidate starting inside it is
ignored), but two comments still described the old strip-first behavior. Update
the _THINK_TAG_RE comment and the parse_tool_calls_from_text docstring.

* Studio: parse Mistral arrays and call-ids, unify bracket parse/strip, keep it linear

- Parse the canonical Mistral array form (TOOL_CALLS followed by a JSON list of
  calls) and emit every call; parse the v11 shape that carries an opaque CALL_ID
  token between the name and ARGS (the function name is the token after
  TOOL_CALLS, never the call-id); and parse a Mistral call plus a rehearsal call
  in one message (the second was dropped yet still stripped from display).
- One shared balanced forward scan (_iter_bracket_spans) backs both the parser
  and the strip path, so they no longer diverge. It is linear: each regex is
  re-searched only once its cached match falls behind the cursor, replacing the
  per-match full-tail re-scan that was O(n^2) (O(n^3) over a stream). A length cap
  before the scan is a backstop.
- strip_tool_call_markup preserves think/reasoning blocks verbatim (the parser
  skips tool markup inside them), stripping only the visible text around them.
- _in_think uses bisect over the sorted think spans (was a linear scan per
  candidate).
- GGUF streaming strip runs the balanced bracket pre-pass before the regex
  patterns so nested-arg calls do not leak or eat trailing prose, and the
  BUFFERING ARGS detector requires the rehearsal name-ARGS shape.
- Tests: canonical array, array string-args, array strip keeps prose, Mistral
  plus rehearsal multi-call, v11 call-id name, think-rehearsal strip
  preservation, and bracket-strip linearity.

* Studio: preserve reasoning blocks in the route and streaming strip paths too

Addresses Gemini/Codex review: making strip_tool_call_markup preserve think
blocks left the route display strip and the GGUF streaming strip inconsistent,
so a rehearsed call inside a reasoning block was still deleted from the visible
text on those paths.

- Extract the think-block segmentation into one shared helper (strip_outside_think)
  and route all three strip paths through it: strip_tool_call_markup,
  _strip_tool_xml_for_display, and the GGUF _strip_tool_markup_streaming closure.
- Add a route-strip regression test that a rehearsal inside a reasoning block is
  preserved while a real call outside it is still stripped.

* Studio: fix bracket-tag strip/buffer review findings

Address the live code-review findings on the Mistral bracket-tag / rehearsal
tool-call rescue path:

- tool_healing: a literal think block inside a tool-call argument is no longer
  treated as a reasoning block. strip_outside_think now excludes think spans
  that sit inside a complete tool-call span, so the call is stripped whole
  instead of the split hiding its open/close pair and leaking the raw call.
- tool_healing: the rehearsal trailing-strip pattern requires a following brace
  or end-of-text, so prose that merely mentions name[ARGS] is not truncated as
  a phantom call. The bracket strip patterns are aligned with the parser
  regexes (whitespace, v11 [CALL_ID]/[ARGS] metadata, and the [CALL_ID]
  lookbehind).
- routes: strip a truncated canonical Mistral array ([TOOL_CALLS] [{... with no
  closing bracket) that the balanced scan cannot remove, align the display
  regex with the parser regexes, and apply the same rehearsal-prose guard.
- safetensors loop: mirror the GGUF [ARGS] rehearsal-substring check during
  BUFFERING so a rehearsal name does not stream before its [ARGS] arrives.

Adds regression tests for each; existing parser suite stays green.

* Studio: hold split rehearsal tool-name prefix in both streaming loops

A reasoning-model rehearsal call can stream the tool name and its [ARGS] arm in
separate chunks (web_search then [ARGS]{...}). The buffering detector only
recognised the rehearsal once [ARGS] was present, so the bare tool name was
emitted as visible content before the call drained and executed.

Add _is_rehearsal_prefix (mirrored in the safetensors loop and the GGUF loop):
when a no-signal buffer is a bare active-tool name -- or a partial prefix of
NAME[ARGS] -- hold it as a prefix instead of streaming it, so the next chunk's
[ARGS] flips it to a drain. A whitespace in the buffer means prose, not a split
call, so ordinary text still streams.

Adds regression tests for the split rehearsal in both loops and a guard that a
plain non-tool word still streams.

* Studio: route Anthropic tool-call cleanup through the protected display strip

The Anthropic stream, non-stream, and passthrough paths cleaned content with raw
_TOOL_XML_RE.sub instead of _strip_tool_xml_for_display, so a rehearsal call
inside <think> was deleted from the reasoning and a nested [TOOL_CALLS] call
dropped its trailing prose (the OpenAI-compatible paths already use the helper).
Route all four sites (prior-assistant cleanup, streaming content events,
non-stream aggregation, passthrough conversion) through the protected helper, and
add a source-level guard test so raw _TOOL_XML_RE.sub stays confined to the
helper itself.

* Studio: stop split rehearsal tool names leaking once streaming, uncapped, or unrestricted

The split-rehearsal guard (NAME in one chunk, [ARGS]{...} in the next) only held
the name in the initial BUFFERING state. Three gaps remained where the bare tool
name still streamed as visible content before the call drained:

- STREAMING: after prose had already streamed, both loops emitted a trailing
  active-tool-name token (and the GGUF/safetensors [ARGS] boundary was not pulled
  back over the name). Hold the trailing rehearsal token and release it on the
  next chunk, with an end-of-stream flush so a plain answer that merely ends on a
  tool-name word is never dropped.
- Buffer cap: a realistic MCP name longer than the 32-char _MAX_BUFFER_CHARS cap
  defeated the BUFFERING hold. A rehearsal prefix is self-bounding (it stops
  matching once it grows past NAME[ARGS]), so the generic cap no longer applies to
  it.
- Unrestricted mode (tools=[]): with no declared tool list, any bare identifier
  may be a NAME[ARGS] rehearsal, so the prefix check now recognises one instead of
  leaking the name and mis-parsing the call.

Regression tests cover the streaming, long-name, and unrestricted cases plus the
plain-prose paths that must not be held or corrupted.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio tools: protect think blocks in safetensors streaming, hold split rehearsal on initial flush, advertise Mistral tools

Pass-3 review follow-ups on the Mistral [TOOL_CALLS] / rehearsal [ARGS] work:

- Safetensors streaming display strip now preserves think / [THINK] reasoning
  verbatim (routes through strip_outside_think like the GGUF path). A call
  rehearsed inside a reasoning block was stripped mid-stream and then restored by
  the final strip, a non-monotonic shrink/grow that corrupted append-by-length
  stream consumers and the visible reasoning.
- The first flush out of BUFFERING (safetensors and GGUF) now applies the same
  trailing-name hold the STREAMING branch uses, so a split rehearsal (prose plus a
  trailing active tool name in one chunk, [ARGS]{...} in the next) no longer leaks
  the bare name before the call drains.
- Safetensors capability gate no longer suppresses tools for Mistral [TOOL_CALLS]
  templates, which the shared bracket-tag parser now handles end to end. Llama
  python_tag stays suppressed (still unparseable).
- Route display strip applies the open-ended / bare-marker tail arms only on the
  segment after the last reasoning block (closed-only regex before it), matching
  strip_tool_call_markup, so a bare foo[ARGS] before a reasoning block is preserved
  while complete calls are still removed in every segment.

Adds regression tests for each and updates the now-stale Mistral capability test.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix tool-call think-marker and bracket-wrapper edge cases

Round-1 review follow-ups on the Mistral/rehearsal tool-call healing:

- tool_healing: a reasoning marker that opens INSIDE a tool call's
  arguments is argument data, not a reasoning block. Add
  _think_spans_outside_tool_markup (start-inside test) and use it in
  both parse_tool_calls_from_text and strip_outside_think so a literal
  marker in one call's args no longer hides a later call (parse) or
  leaks the raw markup (strip) when the greedy match runs past the
  call's closer.
- tool_healing: strip the orphan Mistral v11 [/TOOL_CALLS] closer left
  behind after the balanced scan removes the call body. Add a route arm
  for the same closer in _TOOL_XML_RE / _TOOL_XML_CLOSED_RE.
- safetensors + llama_cpp streaming strip: run the open-ended (EOS
  anchored) tail patterns only on the last segment; segments before a
  reasoning block use the closed-only patterns, matching the final
  strip and the route strip. A bare foo[ARGS] before a reasoning block
  is prose, not a truncated call.
- safetensors streaming detector: validate each [ARGS] hit before
  draining. A bare foo[ARGS] in prose (no active tool name in front)
  no longer drains the rest of the turn; a later real NAME[ARGS] call
  is still found and the prose in between is preserved.

Regression tests added for each case across the parser, strip helpers,
and both streaming loops.

* Strip incomplete-XML tool markup with literal think tags; widen render-html detector

Round-2 review follow-ups.

- tool_healing: an UNCLOSED <tool_call> / <function= call that the parser still
  executes via allow_incomplete leaked its markup when an argument contained a
  literal think marker. _tool_call_markup_spans only covered closed calls, so the
  literal was treated as a reasoning block to preserve. Extend it to the
  open-ended XML tail forms (shared as _TOOL_OPEN_XML_TAIL_PATS) so a think marker
  inside an unclosed call is argument data and the call's markup is stripped. A
  complete call's opener stays bounded to its closed span, and a real reasoning
  block with no tool call is still preserved.
- safetensors render-html provisional card: _detect_render_html_tool_start was
  XML-only, so a Mistral [TOOL_CALLS]render_html or rehearsal render_html[ARGS]
  call executed but skipped the early card. Detect the earliest tool-call marker
  across every serialization the loop executes and fire when it is render_html.

Regression tests added for both.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio tools: gate [ARGS] on active tools and skip think-block render_html rehearsal

Round 3 review fixes for the Mistral / rehearsal tool-call parsing path. Both are
asymmetric-fix bugs where one code path applied a guard the analogous paths did not.

- [ARGS] active-tool gating: the streaming state already validates a rehearsal
  NAME[ARGS] against the active tool list before draining, but the BUFFERING
  detection and the end-of-stream safety-net checks (safetensors and GGUF) treated
  any word[ARGS] substring as a tool boundary. An answer containing a literal
  foo[ARGS]{...} in prose, where foo is not an enabled tool, was drained, parsed into
  a disabled foo no-op, and forced an extra generation turn. Gate those checks on the
  active tool name too (unrestricted mode still accepts any name), so inactive-name
  prose is neither drained nor parsed. Adds a shared _has_genuine_tool_signal helper
  (safetensors) and _gguf_rehearsal_signal_pos / _gguf_has_genuine_tool_signal (GGUF).

- render_html provisional card vs think blocks: the parser skips tool candidates that
  start inside a <think>/[THINK] reasoning block, but the provisional render_html
  detector scanned raw content. A render_html rehearsed inside <think> followed by a
  real non-render_html call emitted a provisional render_html tool_start (reusing the
  later call's id) that the loop never executed. Drop candidates that start inside a
  think span and use the first marker of each shape outside the blocks. Also resolve
  the [TOOL_CALLS] [{...}] array shape through the parser so a nested "name" argument
  key no longer fires a false provisional card ahead of the real top-level tool name.

Adds regression tests for both loops: inactive-name foo[ARGS]{...} is not drained into
a disabled no-op or a retry turn, a think-block render_html rehearsal emits no
provisional card, and the array top-level name is read correctly.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Gate ambiguous bare-rehearsal parse and strip on the active tool list

A bare NAME[ARGS]{json} is a genuine rehearsal call only when NAME is an
active tool; otherwise it is prose. The earlier round gated only detection
(so an inactive foo[ARGS] no longer drained the buffer or forced a retry
turn), but the parse and strip stayed unrestricted, which produced two
regressions:

1. An inactive foo[ARGS]{...} placed immediately before a real
   web_search[ARGS]{...} in the same content span made the real call fail
   to execute (parse consumed the phantom foo call).
2. An inactive foo[ARGS]{...} in a prose answer had its markup stripped
   from the visible text, corrupting the sentence to " is just syntax."

Thread enabled_tool_names through the shared parser/strip so parse and
strip apply the SAME active-tool gate as detection:

- core/tool_healing.py: _iter_bracket_spans skips an inactive rehearsal
  span; parse_tool_calls_from_text, _strip_bracket_tag_calls,
  _strip_markup_segment and strip_tool_call_markup accept and thread the
  gate; apply_tool_strip_patterns keeps an inactive rehearsal match.
- core/inference/tool_call_parser.py: wrappers forward the gate.
- core/inference/safetensors_agentic.py and core/inference/llama_cpp.py:
  compute the gate from the active tool list (None when unrestricted, to
  keep the legacy strip-all behavior) and thread it into every parse and
  streaming/final strip site.
- routes/inference.py: _strip_tool_xml_for_display accepts the gate and
  keeps an inactive rehearsal via a capture group on its rehearsal arm, so
  the display cleanup does not re-strip the already-correct loop output.
  The [TOOL_CALLS] control-token arms still strip unconditionally. Wire
  the current turn's active tool names into the GGUF and safetensors
  content-display sites.

Tests: parse and strip gate coverage in test_tool_call_parser_strict.py,
test_tool_xml_strip.py and test_safetensors_tool_loop.py; end-to-end GGUF
coverage for the real-call-after-inactive-rehearsal case and a
strengthened assertion that the inactive rehearsal prose survives intact.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: render the reasoning block for safetensors and MLX like GGUF

enable_thinking chat templates (Qwen3/Qwen3.5/GLM) prefill an unclosed <think>
into the generation prompt, so the model emits only the closing </think> then
the answer. The safetensors/MLX chat stream emitted that as plain content, so
the reasoning showed inline with no collapsible thinking block, while GGUF
(which surfaces reasoning via reasoning_content) rendered one. This brings
safetensors and MLX to parity.

- _ResponsesReasoningExtractor gains a reasoning_prefilled mode that starts
  inside the reasoning block and splits on the first </think>; default False
  keeps GGUF and every existing caller byte-identical. It suppresses a stray
  re-emitted <think> and holds partial markers back across chunk boundaries.
- _sf_reasoning_prefill_mode gates the mode on reasoning being enabled for the
  request, an enable_thinking or enable_thinking_effort style, and the template
  actually using the standard <think>/</think> markers. Models with a bespoke
  reasoning channel (e.g. gemma's <|think|>/<|channel>) are excluded so their
  answer is never swallowed; gpt-oss (Harmony) and thinking-off requests are
  excluded too.
- sf_tool_stream and stream_chunks (the latter also serves MLX) feed text
  through the extractor, emitting reasoning_content then content deltas, with a
  per-turn reset in the tool loop and a flush before each tool_start; only the
  visible delta reaches the monitor reply. The two non-streaming drains split
  reasoning_content the same way.
- Tests: extractor prefilled mode (streaming and edge cases), the gate matrix
  including the gemma-style exclusion, and a route-replay of the tool-loop
  reasoning stream.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: skip tool calls rehearsed in prefilled reasoning

Reasoning models (Qwen3.5 enable_thinking) open <think> in the prompt, so the
generated text starts inside the thought and emits only a closing </think> with
no opener. _think_spans_outside_tool_markup only found spans with an explicit
opener, so a NAME[ARGS]{...} or [TOOL_CALLS] call rehearsed in that leading
thought was parsed and executed as a real call.

Add a leading think span (offset 0 through the first close marker) when the
content opens with a bare close, so the rehearsed call is skipped and the
reasoning is preserved by strip_outside_think. Guarded by the existing call-span
check: a literal </think> inside a real call's arguments does not trigger the
span, so a genuine leading call still fires. Tests for both cases.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: do not start prefilled reasoning mode when reasoning_effort is none

enable_thinking_effort models (e.g. GLM-5.2) express thinking-off via
reasoning_effort="none" rather than enable_thinking=False, but
_sf_reasoning_prefill_mode only looked at enable_thinking, so such a request
started the extractor in prefilled mode. With thinking off the model never emits
</think>, so the whole answer was captured as reasoning_content and the visible
content/stream came back empty. Thread reasoning_effort through and return False
when it is "none". Tests for none vs a real effort level.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: only treat a leading bare </think> as prefilled reasoning when a real call follows

The prefilled-reasoning virtual span fired on any unmatched leading close marker,
so a non-prefilled turn that emits a real call before a stray </think> (for
example "Now web_search[ARGS]{...}</think> answer") had the call swallowed by the
span and dropped. Require that a real tool call also appear after the close (the
actual turn that follows the thought) before adding the span, so a stray close in
a normal answer no longer suppresses a genuine leading call. The rehearse-then-
call case still skips the rehearsal. Test for the stray-close case.

* Studio: trim redundant comments (comment-only, AST-verified)

* studio: keep tool_healing importable on Python 3.9

_balanced_json_span was annotated -> int | None. With no
from __future__ import annotations, that PEP 604 union is evaluated at
import time, so on Python 3.9 (which the package still supports,
requires-python >=3.9, and where external inference servers import this
module standalone) the def raises TypeError and the whole module fails
to import before any parsing runs.

Add from __future__ import annotations so annotations stay lazy strings,
matching the prevailing convention across studio/backend. No behavior
change: the module has no runtime annotation introspection.

* Studio: gate the Anthropic tool-stream display strip on declared tools

The Anthropic streaming and non-streaming tool paths called
_strip_tool_xml_for_display without enabled_tool_names, so with the default
strip-all behavior a final answer that literally contains an inactive-name
NAME[ARGS]{json} (prose, not a call) lost those bytes in the delivered text.
The GGUF and safetensors paths already pass _display_tool_name_gate(tools);
these two sites were missed when that gate was threaded through.

Compute the gate from the declared tools and pass it at both sites (threading
openai_tools into _anthropic_tool_non_streaming and its caller), so an
inactive-name rehearsal survives while an active-name one is still stripped.
Add a regression test.

* Studio: hold a split unrestricted rehearsal prefix at the bracket

In unrestricted tool mode (tools=[]) the rehearsal-prefix regex required
[A after the bracket, so a chunk boundary landing right after NAME[ (e.g.
web_search[ then ARGS]{...}) failed the prefix check and streamed the
partial tool markup web_search[ to the client before the call drained.
Restricted mode already holds this via a startswith check. Make the bracket
and each ARGS letter individually optional so NAME[ is held too, matching
the documented intent. Add a regression test.

* Studio: gate rehearsal detection and history strip on the original tool set

Two display/loop gate fixes so a spent one-shot tool is handled consistently:

- Rehearsal DETECTION (safetensors and GGUF loops) now uses the ORIGINAL tool
  list, matching the strip gate, instead of the post-removal active_tools. After a
  one-shot tool (render_html) runs it is dropped from active_tools; a repeat
  render_html[ARGS]{...} while another tool is still active was stripped from
  display yet never detected, so it was not routed to the render_html_repeat no-op
  and the turn ended as a blank continuation. Detection now fires for it.

- The GGUF assistant-history sanitiser forwards the enabled-tool-name gate (like
  the live-response strip), so a prior turn documenting an inactive foo[ARGS]{...}
  shape is preserved in the replayed prompt context instead of being deleted.

Add regression tests for both loops and the history strip.

* Studio: thread the tool-name gate through the remaining rehearsal/history sites

Follow-up to the rehearsal-detection and history-strip gate fixes, covering the
sibling sites that were missed:

- GGUF loop: the rehearsal-prefix and trailing-name hold checks now use the
  original tool list (_detect_tools) like the detection path, so a spent one-shot's
  split repeat (bare render_html then [ARGS]{...}) is held instead of flushed as
  visible text.
- The safetensors and Anthropic assistant-history sanitisers and the Anthropic
  non-streaming passthrough now forward the enabled-tool-name gate to
  _strip_tool_xml_for_display, matching the GGUF history sanitiser and the live
  strips, so a prior turn documenting an inactive foo[ARGS]{...} example is
  preserved in the replayed prompt / final text instead of deleted.

Add regression tests.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tile bracket-call spans per array item and include the v11 closer

Two with_spans fixes for the Mistral bracket parser, both hit through the
client-tool passthrough healers:
- A multi-call [TOOL_CALLS] array carried its whole markup span on the first
  call and zero-width spans after, so a consumer that filters promotions by
  the declared tool set either re-emitted the full raw array as text next to
  the promoted call or silently dropped a filtered call's bytes. The region is
  now tiled across the call-producing items (each call's span covers its own
  JSON object plus the separator bytes before it; the last span runs to the
  region end), so promoted markup strips exactly once and a skipped call's
  bytes stay visible.
- The v11 wrapper closer [/TOOL_CALLS] sat outside the reported span and
  leaked as stray text after promotion; the region now extends over an
  immediately-following closer.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address review: decouple healer signals from the loop signal set

The passthrough healer buffered on every TOOL_XML_SIGNALS entry, so the bare
[ARGS] rehearsal marker this branch adds for the loops (where it is gated on
active tool names) put legitimate prose like 'Use foo[ARGS] in templates'
into the holding state and stalled the stream until finalization. The healer
can never promote a bare rehearsal call, so it now buffers only on formats
its parser promotes: <tool_call>, <|tool_call>, <function=, [TOOL_CALLS].

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Condense comments in the Mistral tool-call rescue to contract essentials

* verify_import_hoist: exempt __future__ imports and same-diff relocations

Two false positives fired on this PR's refactor. A from __future__ import
is a compiler directive whose name never appears as a runtime load, so
HOISTED-IMPORT-UNUSED can never see it used, yet the file requires it for
PEP 604 annotations on Python 3.9. TARGET-CHANGED flagged the deliberate
move of the strip-pattern constants into core.inference.tool_call_parser
as a silent re-point even though the old module-level target was removed
and the new one added in the same diff. Both get narrow exemptions; a
re-point to a pre-existing target is still caught, and the self-test
negative controls all pass unchanged.

* Drain the whole Mistral [TOOL_CALLS] array in streaming passthrough healing

StreamToolCallHealer._drain promoted only the first parsed call per pass and
dropped the rest of the buffer past that one span. For a well-formed Mistral
parallel-tool-call array streamed through client-tool passthrough
([TOOL_CALLS][{...},{...}]), the per-item spans are contiguous, so after the
first call was promoted the residue began with ,{...}] (no leading signal) and
was flushed as raw text: every call after the first was lost.

_drain now walks the contiguous run of parsed calls (adjacent tiled spans =
one array), promoting each declared call and relaying undeclared ones as data,
and stops at the first gap (prose) or incomplete trailing block so separate
blocks still stream incrementally in document order. This mirrors the
non-streaming heal_openai_message / finalize promote-or-flush loop and the
server-side safetensors loop, which already handled multi-call arrays.

Added regression tests: 2-call array in one feed and char-by-char, an
undeclared middle call kept as text, and an array followed by trailing prose.

* Drain comma-less Mistral tool-call arrays and normalize null arguments

The array branch fed the whole body to a single json.loads, which rejects the
comma-less multi-call form the repo's own Mistral/Ollama templates render (the
range loop in ollama_template_mappers.py emits the objects with no separator) and
so dropped every call. Decode elements individually with the existing
comma-tolerant raw_decode helper, now _decode_array_items, which also returns the
objects, so all calls are recovered while the span tiling is unchanged.

Also normalize a non-object array argument such as arguments null to an empty
object, matching the wrapped tool_call path, instead of serializing None to the
string "null" that auto-heal would turn into a bogus query of "null".

* Gate safetensors reasoning prefill on the rendered generation prompt

reasoning_always_on fires on any paired <think></think> in the template,
including markup that only renders PAST assistant history (Kimi-K2-Thinking)
while the generation prompt opens no <think>. Starting the reasoning extractor
in prefilled mode there captured a normal answer entirely as reasoning_content
and returned blank visible content. Prefill only when rendering the generation
prompt actually leaves <think> open (DeepSeek-R1 / QwQ / Qwen3-Thinking);
history-only templates start the extractor in normal mode and parse the model's
own <think>...</think>. Adds a Kimi-shape regression test.

* Keep bare scalar Mistral array arguments raw instead of double-encoding

A scalar string argument in the canonical Mistral [TOOL_CALLS] array
(for example [TOOL_CALLS][{"name":"web_search","arguments":"weather"}])
was run through json.dumps, turning weather into the JSON string
"weather". The downstream argument healer then wrapped that quoted
form, so a single-string tool like web_search searched for the literal
"weather" with quotes. The <tool_call> path already keeps a scalar
argument raw; mirror it here so only a dict is serialized. Add a
regression test asserting both paths yield the same healed arguments.

* Tighten tool-call rescue and reasoning-prefill comments

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-06 18:52:13 -07:00

1447 lines
57 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Unit tests for core/inference/passthrough_healing.py: promoting text-form
tool calls back into structured calls on the client-tool passthrough. The
route-level wiring (OpenAI / Anthropic / Responses endpoints) is covered in
their own endpoint test files; this file exercises the shared state machine
and helpers directly.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
from core.inference.passthrough_healing import ( # noqa: E402
StreamToolCallHealer,
heal_gate,
heal_openai_message,
nudge_messages,
nudge_should_retry,
response_has_promotable_calls,
)
TOOLS = [
{"type": "function", "function": {"name": "Bash", "parameters": {}}},
{"type": "function", "function": {"name": "Read", "parameters": {}}},
]
BASH_COMMAND_TOOL = {
"type": "function",
"function": {
"name": "Bash",
"parameters": {
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"],
},
},
}
XML_BASH = '<tool_call>{"name":"Bash","arguments":{"cmd":"ls"}}</tool_call>'
XML_UNDECLARED = '<tool_call>{"name":"Nuke","arguments":{}}</tool_call>'
def _events_text(events):
return "".join(text for kind, text in events if kind == "text")
def _events_calls(events):
return [call for kind, call in events if kind == "tool_call"]
class TestHealGate:
def test_returns_declared_names(self):
assert heal_gate(None, TOOLS) == {"Bash", "Read"}
assert heal_gate(True, TOOLS) == {"Bash", "Read"}
def test_opt_out_and_no_tools(self):
assert heal_gate(False, TOOLS) is None
assert heal_gate(None, []) is None
assert heal_gate(None, None) is None
def test_malformed_tool_entries_ignored(self):
assert heal_gate(None, ["nonsense", {"function": "x"}, {}]) is None
def test_tool_choice_none_disables(self):
assert heal_gate(None, TOOLS, "none") is None
def test_tool_choice_forced_function_narrows_allowlist(self):
forced = {"type": "function", "function": {"name": "Bash"}}
assert heal_gate(None, TOOLS, forced) == {"Bash"}
def test_tool_choice_forced_undeclared_function_disables(self):
forced = {"type": "function", "function": {"name": "Nuke"}}
assert heal_gate(None, TOOLS, forced) is None
def test_tool_choice_auto_and_required_keep_full_set(self):
assert heal_gate(None, TOOLS, "auto") == {"Bash", "Read"}
assert heal_gate(None, TOOLS, "required") == {"Bash", "Read"}
def test_tool_choice_unrecognized_dict_keeps_full_set(self):
assert heal_gate(None, TOOLS, {"type": "function"}) == {"Bash", "Read"}
class TestHealOpenaiMessage:
def test_promotes_xml_and_strips_content(self):
msg = {"role": "assistant", "content": XML_BASH}
assert heal_openai_message(msg, {"Bash"}) is True
assert msg["content"] is None
(call,) = msg["tool_calls"]
assert call["function"]["name"] == "Bash"
assert json.loads(call["function"]["arguments"]) == {"cmd": "ls"}
def test_keeps_surrounding_prose(self):
msg = {"role": "assistant", "content": f"Let me check.\n{XML_BASH}"}
assert heal_openai_message(msg, {"Bash"}) is True
assert msg["content"] == "Let me check."
def test_undeclared_name_not_promoted(self):
msg = {"role": "assistant", "content": XML_UNDECLARED}
assert heal_openai_message(msg, {"Bash"}) is False
assert msg["content"] == XML_UNDECLARED
assert "tool_calls" not in msg
def test_structured_calls_untouched(self):
msg = {"role": "assistant", "content": XML_BASH, "tool_calls": [{"id": "x"}]}
assert heal_openai_message(msg, {"Bash"}) is False
assert msg["content"] == XML_BASH
def test_prose_only_untouched(self):
msg = {"role": "assistant", "content": "just an answer"}
assert heal_openai_message(msg, {"Bash"}) is False
def test_bare_string_arguments_use_schema_key(self):
msg = {
"role": "assistant",
"content": '<tool_call>{"name":"Bash","arguments":"echo hi"}</tool_call>',
}
assert heal_openai_message(msg, {"Bash"}, [BASH_COMMAND_TOOL]) is True
args = json.loads(msg["tool_calls"][0]["function"]["arguments"])
assert args == {"command": "echo hi"}
def test_bare_string_arguments_decline_ambiguous_schema(self):
msg = {
"role": "assistant",
"content": '<tool_call>{"name":"Bash","arguments":"echo hi"}</tool_call>',
}
assert heal_openai_message(msg, {"Bash"}, TOOLS) is False
assert "tool_calls" not in msg
def test_mixed_declared_and_undeclared_promotes_declared_keeps_undeclared_text(self):
# Span-exact removal: only the promoted Bash markup is dropped; the
# undeclared Nuke call's text stays in the content byte-intact.
content = f"pre {XML_BASH} mid {XML_UNDECLARED} post"
msg = {"role": "assistant", "content": content}
assert heal_openai_message(msg, {"Bash"}) is True
(call,) = msg["tool_calls"]
assert call["function"]["name"] == "Bash"
assert XML_UNDECLARED in msg["content"]
assert "pre" in msg["content"] and "post" in msg["content"]
assert XML_BASH not in msg["content"]
def test_multiple_declared_calls_all_promoted(self):
content = f"{XML_BASH} and {XML_BASH}"
msg = {"role": "assistant", "content": content}
assert heal_openai_message(msg, {"Bash"}) is True
assert len(msg["tool_calls"]) == 2
def test_mixed_formats_promote_in_document_order(self):
func_read = "<function=Read><parameter=path>a.txt</parameter></function>"
content = f"{func_read} then {XML_BASH}"
msg = {"role": "assistant", "content": content}
assert heal_openai_message(msg, {"Bash", "Read"}) is True
assert [call["function"]["name"] for call in msg["tool_calls"]] == ["Read", "Bash"]
assert msg["content"] == "then"
def test_unparseable_closed_block_not_deleted(self):
# A closed <tool_call> block whose body never parses is model output,
# not a promotable call; it must survive promotion of its neighbor.
garbage = "<tool_call>not json at all</tool_call>"
content = f"{XML_BASH} {garbage}"
msg = {"role": "assistant", "content": content}
assert heal_openai_message(msg, {"Bash"}) is True
assert garbage in msg["content"]
class TestStreamHealer:
def test_plain_text_passes_through(self):
healer = StreamToolCallHealer({"Bash"})
events = healer.feed("hello ") + healer.feed("world") + healer.finalize()
assert _events_text(events) == "hello world"
assert not _events_calls(events)
def test_complete_call_in_one_chunk(self):
healer = StreamToolCallHealer({"Bash"})
events = healer.feed(f"On it. {XML_BASH}") + healer.finalize()
assert _events_text(events) == "On it. "
(call,) = _events_calls(events)
assert call["function"]["name"] == "Bash"
assert healer.healed
def test_signal_split_across_chunks(self):
healer = StreamToolCallHealer({"Bash"})
events = []
for piece in ["<tool", '_call>{"name":"Bash",', '"arguments":{}}</tool_call>']:
events += healer.feed(piece)
events += healer.finalize()
assert _events_text(events) == ""
assert len(_events_calls(events)) == 1
def test_closed_malformed_tool_block_flushes_immediately(self):
healer = StreamToolCallHealer({"Bash"})
events = healer.feed("<tool_call>not json</tool_call> after")
assert _events_text(events) == "<tool_call>not json</tool_call> after"
assert not _events_calls(events)
def test_mixed_formats_stream_in_document_order(self):
healer = StreamToolCallHealer({"Bash", "Read"})
func_read = "<function=Read><parameter=path>a.txt</parameter></function>"
events = healer.feed(f"{func_read} then {XML_BASH}") + healer.finalize()
assert [call["function"]["name"] for call in _events_calls(events)] == ["Read", "Bash"]
assert _events_text(events).strip() == "then"
def test_false_alarm_html_flushes(self):
healer = StreamToolCallHealer({"Bash"})
events = healer.feed("use the <div> tag") + healer.finalize()
assert _events_text(events) == "use the <div> tag"
assert not _events_calls(events)
def test_partial_signal_tail_held_then_flushed_at_end(self):
healer = StreamToolCallHealer({"Bash"})
events = healer.feed("trailing <tool")
assert _events_text(events) == "trailing " # tail held back
events += healer.finalize()
assert _events_text(events) == "trailing <tool"
def test_mixed_calls_promote_declared_flush_undeclared_in_order(self):
# Declared + undeclared in the same buffer: the declared call is
# promoted, the undeclared markup flushes as text, and event order
# follows document order (call first here, since it came first).
healer = StreamToolCallHealer({"Bash"})
text = f"{XML_BASH} then {XML_UNDECLARED} post"
events = healer.feed(text) + healer.finalize()
assert [k for k, _ in events if k == "tool_call"] == ["tool_call"]
assert events[0][0] == "tool_call"
joined = _events_text(events)
assert XML_UNDECLARED in joined
assert "then" in joined and "post" in joined
def test_text_between_two_healed_calls_keeps_document_order(self):
# call A, " middle ", call B in ONE buffer must stream as
# call A -> text -> call B, never both calls then the text.
healer = StreamToolCallHealer({"Bash"})
events = healer.feed(f"{XML_BASH} middle {XML_BASH}") + healer.finalize()
kinds = [k for k, _ in events]
assert kinds == ["tool_call", "text", "tool_call"]
assert events[1][1] == " middle "
def test_undeclared_then_declared_keeps_document_order(self):
# The undeclared block precedes the declared call; its raw text must
# be emitted BEFORE the promoted call event, never after.
healer = StreamToolCallHealer({"Bash"})
events = healer.feed(f"{XML_UNDECLARED} then {XML_BASH}") + healer.finalize()
kinds = [k for k, _ in events]
assert kinds.index("tool_call") == len(kinds) - 1
(call,) = _events_calls(events)
assert call["function"]["name"] == "Bash"
assert XML_UNDECLARED in _events_text(events)
def test_declared_promoted_then_late_undeclared_flushes_raw(self):
# Streaming causality: the declared call completed and was already
# emitted before the undeclared one arrived. The undeclared markup
# must still reach the client as raw text (no data loss).
healer = StreamToolCallHealer({"Bash"})
events = healer.feed(f"{XML_BASH} then ")
assert len(_events_calls(events)) == 1
events += healer.feed(XML_UNDECLARED) + healer.finalize()
assert XML_UNDECLARED in _events_text(events)
assert len(_events_calls(events)) == 1
def test_undeclared_tool_flushes_raw(self):
healer = StreamToolCallHealer({"Bash"})
events = healer.feed(XML_UNDECLARED) + healer.finalize()
assert _events_text(events) == XML_UNDECLARED
assert not _events_calls(events)
def test_two_calls_and_text_between(self):
healer = StreamToolCallHealer({"Bash", "Read"})
xml_read = '<tool_call>{"name":"Read","arguments":{"path":"f"}}</tool_call>'
events = healer.feed(f"{XML_BASH} then {xml_read}") + healer.finalize()
calls = _events_calls(events)
assert [c["function"]["name"] for c in calls] == ["Bash", "Read"]
assert [c["id"] for c in calls] == ["call_0", "call_1"]
assert _events_text(events).strip() == "then"
def test_mistral_array_multiple_calls_all_promoted_in_stream(self):
# A canonical Mistral [TOOL_CALLS] array carries several calls under a
# SINGLE signal. Draining only the first call would leave the residue
# starting at ",{...}]" (no signal), so later calls in the same array
# must be promoted in the same pass, not flushed as raw text.
healer = StreamToolCallHealer({"get_weather", "get_time"})
array = (
'[TOOL_CALLS][{"name":"get_weather","arguments":{"city":"Paris"}},'
'{"name":"get_time","arguments":{"tz":"UTC"}}]'
)
events = healer.feed(array) + healer.finalize()
calls = _events_calls(events)
assert [c["function"]["name"] for c in calls] == ["get_weather", "get_time"]
assert [c["id"] for c in calls] == ["call_0", "call_1"]
assert _events_text(events) == ""
def test_mistral_array_multiple_calls_promoted_char_by_char(self):
healer = StreamToolCallHealer({"get_weather", "get_time"})
array = (
'[TOOL_CALLS][{"name":"get_weather","arguments":{"city":"Paris"}},'
'{"name":"get_time","arguments":{"tz":"UTC"}}]'
)
events = []
for ch in array:
events += healer.feed(ch)
events += healer.finalize()
calls = _events_calls(events)
assert [c["function"]["name"] for c in calls] == ["get_weather", "get_time"]
assert _events_text(events) == ""
def test_mistral_array_undeclared_middle_kept_as_text_others_promoted(self):
# A mid-array element for a tool that is not declared must survive as
# text while the declared neighbours on either side still promote in
# document order.
healer = StreamToolCallHealer({"a", "c"})
array = (
'[TOOL_CALLS][{"name":"a","arguments":{}},'
'{"name":"b","arguments":{}},{"name":"c","arguments":{}}]'
)
events = healer.feed(array) + healer.finalize()
assert [c["function"]["name"] for c in _events_calls(events)] == ["a", "c"]
assert '"b"' in _events_text(events)
def test_mistral_array_then_trailing_prose(self):
healer = StreamToolCallHealer({"a", "b"})
array = '[TOOL_CALLS][{"name":"a","arguments":{}},{"name":"b","arguments":{}}]'
events = healer.feed(f"{array} all done") + healer.finalize()
assert [c["function"]["name"] for c in _events_calls(events)] == ["a", "b"]
assert "all done" in _events_text(events)
def test_incomplete_call_healed_at_finalize(self):
healer = StreamToolCallHealer({"Bash"})
events = healer.feed('<tool_call>{"name":"Bash","arguments":{"cmd":"ls"}}')
assert events == [] # held
events = healer.finalize()
(call,) = _events_calls(events)
assert call["function"]["name"] == "Bash"
def test_teaching_text_flushes_at_finalize(self):
healer = StreamToolCallHealer({"Bash"})
events = healer.feed("<tool_call> is the marker syntax") + healer.finalize()
assert _events_text(events) == "<tool_call> is the marker syntax"
assert not _events_calls(events)
def test_hold_bound_flushes(self):
healer = StreamToolCallHealer({"Bash"})
blob = "<tool_call>" + "x" * (64 * 1024 + 10)
events = healer.feed(blob) + healer.finalize()
assert _events_text(events) == blob
assert not _events_calls(events)
def test_dormant_after_structured_delta(self):
healer = StreamToolCallHealer({"Bash"})
held = healer.feed("prefix <tool")
flush = healer.structured_tool_call_seen()
after = healer.feed(XML_BASH) + healer.finalize()
assert _events_text(held + flush + after) == f"prefix <tool{XML_BASH}"
assert not _events_calls(after)
class TestNudgeHelpers:
def _resp(
self,
content,
tool_calls = None,
):
msg = {"role": "assistant", "content": content}
if tool_calls:
msg["tool_calls"] = tool_calls
return {"choices": [{"message": msg, "finish_reason": "stop"}]}
def test_retry_on_unparseable_signal(self):
# Signal present but the JSON never parses and no declared name matches.
data = self._resp("<tool_call>call Bash somehow???")
assert nudge_should_retry(data, {"Read"}) is True
def test_no_retry_on_clean_prose(self):
assert nudge_should_retry(self._resp("all done"), {"Bash"}) is False
def test_no_retry_when_heal_would_succeed(self):
assert nudge_should_retry(self._resp(XML_BASH), {"Bash"}) is False
def test_no_retry_with_structured_calls(self):
data = self._resp("", tool_calls = [{"id": "x"}])
assert nudge_should_retry(data, {"Bash"}) is False
def test_no_retry_when_healing_disabled(self):
assert nudge_should_retry(self._resp("<tool_call>???"), None) is False
def test_nudge_messages_shape(self):
data = self._resp("<tool_call>garbage")
suffix = nudge_messages(data, {"Bash", "Read"})
assert [m["role"] for m in suffix] == ["assistant", "user"]
assert suffix[0]["content"] == "<tool_call>garbage"
assert "`Bash` or `Read`" in suffix[1]["content"]
def test_retry_with_undeclared_structured_call_is_not_an_improvement(self):
# The retry replaces the original only when it carries a USABLE call:
# a structured call naming an undeclared tool must not count.
undeclared = [
{"id": "x", "type": "function", "function": {"name": "Nuke", "arguments": "{}"}}
]
declared = [
{"id": "y", "type": "function", "function": {"name": "Bash", "arguments": "{}"}}
]
assert response_has_promotable_calls(self._resp("", undeclared), {"Bash"}) is False
assert response_has_promotable_calls(self._resp("", declared), {"Bash"}) is True
def test_retry_with_mixed_structured_calls_is_not_an_improvement(self):
# ALL structured calls must be declared: the caller forwards the whole
# list (and a parallel cap could keep only the FIRST), so a mixed retry
# could still hand the client an undeclared tool.
mixed = [
{"id": "x", "type": "function", "function": {"name": "Nuke", "arguments": "{}"}},
{"id": "y", "type": "function", "function": {"name": "Bash", "arguments": "{}"}},
]
assert response_has_promotable_calls(self._resp("", mixed), {"Bash"}) is False
assert (
response_has_promotable_calls(self._resp("", list(reversed(mixed))), {"Bash"}) is False
)
@pytest.mark.parametrize(
"data",
[
None,
"not a dict",
{},
{"choices": []},
{"choices": [{}]},
{"choices": [{"message": None}]}, # llama-server error bodies do this
{"choices": [{"message": "not a dict"}]},
{"choices": [{"message": {"content": None}}]},
{"error": {"message": "boom"}},
],
)
def test_malformed_response_shapes_never_raise(self, data):
# A malformed upstream body must degrade to "nothing to heal/nudge",
# never crash the request with an AttributeError.
assert nudge_should_retry(data, {"Bash"}) is False
assert response_has_promotable_calls(data, {"Bash"}) is False
suffix = nudge_messages(data, {"Bash"})
assert suffix[0] == {"role": "assistant", "content": ""}
# ── Route-level wiring (OpenAI passthrough) ─────────────────────────────
# Mirrors the fake-llama-server patterns in test_openai_tool_passthrough.py.
import asyncio # noqa: E402
import threading # noqa: E402
from types import SimpleNamespace # noqa: E402
import httpx # noqa: E402
from core.inference.api_monitor import ApiMonitor # noqa: E402
from models.inference import ChatCompletionRequest, ChatMessage # noqa: E402
from routes.inference import ( # noqa: E402
_openai_passthrough_non_streaming,
_openai_passthrough_stream,
)
LOOKUP_TOOL = {
"type": "function",
"function": {"name": "lookup", "parameters": {"type": "object", "properties": {}}},
}
LOOKUP_XML = '<tool_call>{"name":"lookup","arguments":{"q":"x"}}</tool_call>'
def _payload(**kwargs):
defaults = dict(
model = "default",
messages = [ChatMessage(role = "user", content = "hi")],
tools = [LOOKUP_TOOL],
)
defaults.update(kwargs)
return ChatCompletionRequest(**defaults)
def _llama_backend():
return SimpleNamespace(
base_url = "http://llama.test",
context_length = 4096,
_request_reasoning_kwargs = lambda *_args, **_kwargs: None,
)
def _upstream_message(
content,
tool_calls = None,
finish_reason = "stop",
):
message = {"role": "assistant", "content": content}
if tool_calls is not None:
message["tool_calls"] = tool_calls
return {
"id": "chatcmpl-up",
"object": "chat.completion",
"created": 1,
"model": "gguf",
"choices": [{"index": 0, "message": message, "finish_reason": finish_reason}],
"usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3},
}
class ScriptedClient:
"""Fake nonstreaming_client() returning scripted JSON bodies, counting POSTs."""
def __init__(self, bodies):
self.bodies = list(bodies)
self.posts = []
async def post(
self,
_url,
json = None,
timeout = None,
):
self.posts.append(json)
return httpx.Response(200, json = self.bodies[min(len(self.posts) - 1, len(self.bodies) - 1)])
async def _drive_non_streaming(monkeypatch, payload, bodies):
import routes.inference as inf_mod
client = ScriptedClient(bodies)
monkeypatch.setattr(inf_mod, "nonstreaming_client", lambda: client)
response = await _openai_passthrough_non_streaming(
_llama_backend(), payload, "gguf", monitor_id = None
)
return client, json.loads(response.body)
async def _drive_stream(monkeypatch, payload, lines):
import routes.inference as inf_mod
class Request:
async def is_disconnected(self):
return False
async def fake_send(*_args, **_kwargs):
return httpx.Response(200, content = b"")
async def fake_items(*_args, **_kwargs):
for line in lines:
yield line
monkeypatch.setattr(inf_mod, "_send_stream_with_preheader_cancel", fake_send)
monkeypatch.setattr(inf_mod, "_aiter_llama_stream_items", fake_items)
monkeypatch.setattr(inf_mod, "api_monitor", ApiMonitor(max_entries = 3))
response = await _openai_passthrough_stream(
Request(),
threading.Event(),
_llama_backend(),
payload,
"gguf",
"chatcmpl-test",
monitor_id = None,
)
return [chunk async for chunk in response.body_iterator]
def _stream_payloads(chunks):
out = []
for chunk in chunks:
for line in chunk.splitlines():
if line.startswith("data: ") and line[6:] != "[DONE]":
out.append(json.loads(line[6:]))
return out
class TestOpenaiNonStreamingRoute:
def test_heals_xml_to_tool_calls(self, monkeypatch):
async def _run():
client, data = await _drive_non_streaming(
monkeypatch, _payload(), [_upstream_message(LOOKUP_XML)]
)
choice = data["choices"][0]
assert choice["finish_reason"] == "tool_calls"
(call,) = choice["message"]["tool_calls"]
assert call["function"]["name"] == "lookup"
assert json.loads(call["function"]["arguments"]) == {"q": "x"}
assert choice["message"]["content"] is None
assert data["usage"]["total_tokens"] == 3 # usage preserved
assert len(client.posts) == 1 # healing never re-requests
asyncio.run(_run())
def test_bare_string_uses_client_schema_key(self, monkeypatch):
async def _run():
content = '<tool_call>{"name":"Bash","arguments":"echo hi"}</tool_call>'
_, data = await _drive_non_streaming(
monkeypatch,
_payload(tools = [BASH_COMMAND_TOOL]),
[_upstream_message(content)],
)
(call,) = data["choices"][0]["message"]["tool_calls"]
assert json.loads(call["function"]["arguments"]) == {"command": "echo hi"}
asyncio.run(_run())
def test_opt_out_relays_verbatim(self, monkeypatch):
async def _run():
_, data = await _drive_non_streaming(
monkeypatch,
_payload(auto_heal_tool_calls = False),
[_upstream_message(LOOKUP_XML)],
)
choice = data["choices"][0]
assert choice["message"]["content"] == LOOKUP_XML
assert "tool_calls" not in choice["message"]
assert choice["finish_reason"] == "stop"
asyncio.run(_run())
def test_no_tools_untouched(self, monkeypatch):
async def _run():
_, data = await _drive_non_streaming(
monkeypatch, _payload(tools = None), [_upstream_message(LOOKUP_XML)]
)
assert data["choices"][0]["message"]["content"] == LOOKUP_XML
asyncio.run(_run())
def test_undeclared_tool_not_promoted(self, monkeypatch):
async def _run():
xml = '<tool_call>{"name":"rogue","arguments":{}}</tool_call>'
_, data = await _drive_non_streaming(monkeypatch, _payload(), [_upstream_message(xml)])
assert data["choices"][0]["message"]["content"] == xml
assert "tool_calls" not in data["choices"][0]["message"]
asyncio.run(_run())
def test_structured_calls_untouched(self, monkeypatch):
async def _run():
native = [
{
"id": "call_up",
"type": "function",
"function": {"name": "lookup", "arguments": "{}"},
}
]
_, data = await _drive_non_streaming(
monkeypatch,
_payload(),
[_upstream_message("", tool_calls = native, finish_reason = "tool_calls")],
)
assert data["choices"][0]["message"]["tool_calls"] == native
asyncio.run(_run())
def test_length_finish_reason_preserved(self, monkeypatch):
async def _run():
# Truncated generation: the healed call stays attached but the
# client must still see the truncation, so length is never
# upgraded to tool_calls.
_, data = await _drive_non_streaming(
monkeypatch,
_payload(),
[_upstream_message(LOOKUP_XML, finish_reason = "length")],
)
choice = data["choices"][0]
assert choice["finish_reason"] == "length"
(call,) = choice["message"]["tool_calls"]
assert call["function"]["name"] == "lookup"
asyncio.run(_run())
def test_tool_choice_none_relays_verbatim(self, monkeypatch):
async def _run():
_, data = await _drive_non_streaming(
monkeypatch,
_payload(tool_choice = "none"),
[_upstream_message(LOOKUP_XML)],
)
message = data["choices"][0]["message"]
assert message["content"] == LOOKUP_XML
assert "tool_calls" not in message
asyncio.run(_run())
def test_tool_choice_forcing_other_function_not_promoted(self, monkeypatch):
async def _run():
_, data = await _drive_non_streaming(
monkeypatch,
_payload(tool_choice = {"type": "function", "function": {"name": "other"}}),
[_upstream_message(LOOKUP_XML)],
)
message = data["choices"][0]["message"]
assert message["content"] == LOOKUP_XML
assert "tool_calls" not in message
asyncio.run(_run())
def test_mixed_declared_and_undeclared_promotes_and_keeps_text(self, monkeypatch):
async def _run():
rogue = '<tool_call>{"name":"rogue","arguments":{}}</tool_call>'
mixed = f"{LOOKUP_XML} also {rogue}"
_, data = await _drive_non_streaming(
monkeypatch, _payload(), [_upstream_message(mixed)]
)
choice = data["choices"][0]
(call,) = choice["message"]["tool_calls"]
assert call["function"]["name"] == "lookup"
assert rogue in choice["message"]["content"]
assert choice["finish_reason"] == "tool_calls"
asyncio.run(_run())
def test_healed_then_native_stream_indexes_disjoint(self, monkeypatch):
async def _run():
# A healed text-form call goes out first (index 0); a native
# structured delta follows. Clients merge deltas by index, so the
# native call must be shifted off index 0 or the two would merge.
native_line = (
'data: {"id":"c1","choices":[{"index":0,"delta":{"tool_calls":'
'[{"index":0,"id":"call_native","type":"function","function":'
'{"name":"lookup","arguments":"{}"}}]}}]}'
)
lines = [
'data: {"id":"c1","choices":[{"index":0,"delta":{"content":'
+ json.dumps(LOOKUP_XML)
+ "}}]}",
native_line,
'data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}',
"data: [DONE]",
]
chunks = await _drive_stream(monkeypatch, _payload(stream = True), lines)
indexes = {}
for payload_data in _stream_payloads(chunks):
for ch in payload_data.get("choices", []):
for tc in (ch.get("delta") or {}).get("tool_calls") or []:
indexes.setdefault(tc["index"], tc.get("id"))
assert indexes.get(0, "").startswith("call_") and indexes[0] != "call_native"
assert indexes.get(1) == "call_native"
asyncio.run(_run())
def test_role_delta_precedes_healed_stream_content(self, monkeypatch):
async def _run():
lines = [
'data: {"id":"c1","choices":[{"index":0,"delta":{"role":"assistant","content":'
+ json.dumps(LOOKUP_XML)
+ "}}]}",
'data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
"data: [DONE]",
]
chunks = await _drive_stream(monkeypatch, _payload(stream = True), lines)
payloads = _stream_payloads(chunks)
first_delta = payloads[0]["choices"][0]["delta"]
assert first_delta == {"role": "assistant"}
assert "tool_calls" in payloads[1]["choices"][0]["delta"]
asyncio.run(_run())
def test_same_chunk_role_content_finish_delays_finish_until_after_healed_tool(
self, monkeypatch
):
async def _run():
lines = [
'data: {"id":"c1","choices":[{"index":0,"delta":{"role":"assistant","content":'
+ json.dumps(LOOKUP_XML)
+ '},"finish_reason":"stop"}]}',
"data: [DONE]",
]
chunks = await _drive_stream(monkeypatch, _payload(stream = True), lines)
payloads = _stream_payloads(chunks)
assert payloads[0]["choices"][0]["finish_reason"] is None
assert payloads[0]["choices"][0]["delta"] == {"role": "assistant"}
assert "tool_calls" in payloads[1]["choices"][0]["delta"]
assert payloads[-1]["choices"][0]["finish_reason"] == "tool_calls"
asyncio.run(_run())
GARBAGE_SIGNAL = "<tool_call>call lookup somehow???"
class TestNudgeRetryOpenai:
def test_retry_recovers_call(self, monkeypatch):
async def _run():
client, data = await _drive_non_streaming(
monkeypatch,
_payload(nudge_tool_calls = True),
[_upstream_message(GARBAGE_SIGNAL), _upstream_message(LOOKUP_XML)],
)
assert len(client.posts) == 2 # exactly one retry
# Prefix byte-identical, nudge suffix appended (KV-cache reuse guard).
original, retry = client.posts
assert retry["messages"][: len(original["messages"])] == original["messages"]
suffix = retry["messages"][len(original["messages"]) :]
assert [m["role"] for m in suffix] == ["assistant", "user"]
assert suffix[0]["content"] == GARBAGE_SIGNAL
# The healed retry response is returned.
(call,) = data["choices"][0]["message"]["tool_calls"]
assert call["function"]["name"] == "lookup"
assert data["choices"][0]["finish_reason"] == "tool_calls"
asyncio.run(_run())
def test_retry_still_garbage_returns_original(self, monkeypatch):
async def _run():
client, data = await _drive_non_streaming(
monkeypatch,
_payload(nudge_tool_calls = True),
[_upstream_message(GARBAGE_SIGNAL), _upstream_message(GARBAGE_SIGNAL + "2")],
)
assert len(client.posts) == 2
assert data["choices"][0]["message"]["content"] == GARBAGE_SIGNAL
assert "tool_calls" not in data["choices"][0]["message"]
asyncio.run(_run())
def test_default_off_single_post(self, monkeypatch):
async def _run():
client, _ = await _drive_non_streaming(
monkeypatch, _payload(), [_upstream_message(GARBAGE_SIGNAL)]
)
assert len(client.posts) == 1
asyncio.run(_run())
def test_no_retry_on_clean_prose(self, monkeypatch):
async def _run():
client, _ = await _drive_non_streaming(
monkeypatch,
_payload(nudge_tool_calls = True),
[_upstream_message("all done")],
)
assert len(client.posts) == 1
asyncio.run(_run())
def test_no_retry_when_heal_succeeds(self, monkeypatch):
async def _run():
client, data = await _drive_non_streaming(
monkeypatch,
_payload(nudge_tool_calls = True),
[_upstream_message(LOOKUP_XML)],
)
assert len(client.posts) == 1
assert data["choices"][0]["message"]["tool_calls"]
asyncio.run(_run())
def test_heal_opt_out_disables_nudge_too(self, monkeypatch):
async def _run():
client, _ = await _drive_non_streaming(
monkeypatch,
_payload(auto_heal_tool_calls = False, nudge_tool_calls = True),
[_upstream_message(GARBAGE_SIGNAL)],
)
assert len(client.posts) == 1
asyncio.run(_run())
class TestNudgeRetryAnthropic:
async def _drive(
self,
monkeypatch,
bodies,
nudge = None,
):
import routes.inference as inf_mod
from routes.inference import _anthropic_passthrough_non_streaming
client = ScriptedClient(bodies)
monkeypatch.setattr(inf_mod, "nonstreaming_client", lambda: client)
response = await _anthropic_passthrough_non_streaming(
_llama_backend(),
[{"role": "user", "content": "hi"}],
[LOOKUP_TOOL],
0.7,
0.95,
None,
256,
"msg_test",
"gguf",
nudge_tool_calls = nudge,
)
return client, json.loads(response.body)
def test_retry_recovers_tool_use(self, monkeypatch):
async def _run():
client, data = await self._drive(
monkeypatch,
[_upstream_message(GARBAGE_SIGNAL), _upstream_message(LOOKUP_XML)],
nudge = True,
)
assert len(client.posts) == 2
(block,) = [b for b in data["content"] if b["type"] == "tool_use"]
assert block["name"] == "lookup"
assert data["stop_reason"] == "tool_use"
asyncio.run(_run())
def test_healed_tool_use_precedes_trailing_text(self, monkeypatch):
async def _run():
_, data = await self._drive(monkeypatch, [_upstream_message(f"{LOOKUP_XML} done")])
assert [block["type"] for block in data["content"]] == ["tool_use", "text"]
assert data["content"][1]["text"] == "done"
asyncio.run(_run())
def test_default_off(self, monkeypatch):
async def _run():
client, _ = await self._drive(monkeypatch, [_upstream_message(GARBAGE_SIGNAL)])
assert len(client.posts) == 1
asyncio.run(_run())
class TestAnthropicPassthroughHealingText:
"""Non-streaming Anthropic passthrough must relay unpromoted (undeclared)
text-form calls as text, matching the OpenAI passthrough contract. Once
heal_openai_message promotes the declared call it span-trims only that
markup and deliberately leaves the undeclared bytes in the content; the
legacy blanket _TOOL_XML_RE strip must not delete them.
"""
async def _drive(self, monkeypatch, upstream):
import routes.inference as inf_mod
from routes.inference import _anthropic_passthrough_non_streaming
client = ScriptedClient([upstream])
monkeypatch.setattr(inf_mod, "nonstreaming_client", lambda: client)
response = await _anthropic_passthrough_non_streaming(
_llama_backend(),
[{"role": "user", "content": "hi"}],
[LOOKUP_TOOL],
0.7,
0.95,
None,
256,
"msg_test",
"gguf",
)
return json.loads(response.body)
def test_mixed_declared_and_undeclared_relays_undeclared_as_text(self, monkeypatch):
async def _run():
content = f"Running now. {LOOKUP_XML} then {XML_UNDECLARED} done."
data = await self._drive(monkeypatch, _upstream_message(content))
# Declared lookup call is promoted into a structured tool_use block.
(tool_use,) = [b for b in data["content"] if b["type"] == "tool_use"]
assert tool_use["name"] == "lookup"
text = " ".join(b["text"] for b in data["content"] if b["type"] == "text")
assert XML_UNDECLARED in text
assert "Running now." in text and "done." in text
assert LOOKUP_XML not in text
asyncio.run(_run())
class TestAnthropicEmitterHealing:
def _events(
self,
emitter,
chunks,
finish = True,
):
lines = []
for chunk in chunks:
lines += emitter.feed_chunk(chunk)
if finish:
lines += emitter.finish()
return [json.loads(ln.split("data: ", 1)[1]) for ln in lines if "data: " in ln]
def _emitter(
self,
allowed = ("lookup",),
**kwargs,
):
from core.inference.anthropic_compat import AnthropicPassthroughEmitter
emitter = AnthropicPassthroughEmitter()
emitter.enable_healing(set(allowed), **kwargs)
return emitter
def _chunk(
self,
content = None,
tool_calls = None,
finish_reason = None,
):
delta = {}
if content is not None:
delta["content"] = content
if tool_calls is not None:
delta["tool_calls"] = tool_calls
return {"choices": [{"delta": delta, "finish_reason": finish_reason}]}
def test_xml_becomes_tool_use_block_and_stop_reason(self):
events = self._events(
self._emitter(),
[
self._chunk(content = LOOKUP_XML),
self._chunk(finish_reason = "stop"),
],
)
starts = [e for e in events if e.get("type") == "content_block_start"]
(tool_start,) = [e for e in starts if e["content_block"]["type"] == "tool_use"]
assert tool_start["content_block"]["name"] == "lookup"
assert tool_start["content_block"]["id"].startswith("toolu_")
(args,) = [
e["delta"]["partial_json"]
for e in events
if e.get("type") == "content_block_delta" and e["delta"]["type"] == "input_json_delta"
]
assert json.loads(args) == {"q": "x"}
(message_delta,) = [e for e in events if e.get("type") == "message_delta"]
assert message_delta["delta"]["stop_reason"] == "tool_use"
def test_mid_block_signal_closes_text_block_first(self):
events = self._events(
self._emitter(),
[
self._chunk(content = f"Let me check {LOOKUP_XML}"),
self._chunk(finish_reason = "stop"),
],
)
kinds = [
(e["type"], (e.get("content_block") or e.get("delta") or {}).get("type"))
for e in events
if e["type"].startswith("content_block")
]
# text opens, streams the safe prefix, closes; then the tool_use block.
assert kinds[0] == ("content_block_start", "text")
assert kinds[1] == ("content_block_delta", "text_delta")
assert kinds[2] == ("content_block_stop", None)
assert kinds[3] == ("content_block_start", "tool_use")
texts = [
e["delta"]["text"]
for e in events
if e.get("type") == "content_block_delta" and e["delta"]["type"] == "text_delta"
]
assert "".join(texts) == "Let me check "
def test_false_alarm_streams_as_text(self):
events = self._events(
self._emitter(),
[self._chunk(content = "use the <div> tag"), self._chunk(finish_reason = "stop")],
)
texts = [
e["delta"]["text"]
for e in events
if e.get("type") == "content_block_delta" and e["delta"]["type"] == "text_delta"
]
assert "".join(texts) == "use the <div> tag"
(message_delta,) = [e for e in events if e.get("type") == "message_delta"]
assert message_delta["delta"]["stop_reason"] == "end_turn"
def test_signal_split_across_chunks(self):
events = self._events(
self._emitter(),
[
self._chunk(content = "<tool"),
self._chunk(content = '_call>{"name":"lookup","arguments":{}}'),
self._chunk(finish_reason = "stop"),
],
)
starts = [e for e in events if e.get("type") == "content_block_start"]
assert [e["content_block"]["type"] for e in starts] == ["tool_use"]
texts = [
e
for e in events
if e.get("type") == "content_block_delta" and e["delta"]["type"] == "text_delta"
]
assert texts == []
def test_max_tokens_wins_over_healed_stop_reason(self):
events = self._events(
self._emitter(),
[self._chunk(content = LOOKUP_XML), self._chunk(finish_reason = "length")],
)
(message_delta,) = [e for e in events if e.get("type") == "message_delta"]
assert message_delta["delta"]["stop_reason"] == "max_tokens"
def test_structured_deltas_disable_healing_and_flush(self):
structured = [
{
"index": 0,
"id": "call_up",
"function": {"name": "lookup", "arguments": "{}"},
}
]
events = self._events(
self._emitter(),
[
self._chunk(content = "held <tool"),
self._chunk(tool_calls = structured),
self._chunk(finish_reason = "tool_calls"),
],
)
texts = [
e["delta"]["text"]
for e in events
if e.get("type") == "content_block_delta" and e["delta"]["type"] == "text_delta"
]
assert "".join(texts) == "held <tool" # nothing swallowed
starts = [e for e in events if e.get("type") == "content_block_start"]
assert [e["content_block"]["type"] for e in starts] == ["text", "tool_use"]
def test_disable_parallel_caps_healed_calls(self):
two = LOOKUP_XML + '<tool_call>{"name":"lookup","arguments":{"q":"y"}}</tool_call>'
events = self._events(
self._emitter(disable_parallel_tool_use = True),
[self._chunk(content = two), self._chunk(finish_reason = "stop")],
)
starts = [
e
for e in events
if e.get("type") == "content_block_start" and e["content_block"]["type"] == "tool_use"
]
assert len(starts) == 1
def test_disable_parallel_drops_native_after_healed(self):
# A healed call consumed the single allowed slot; a later native
# structured call (index 0, so it survives the caller's chunk-level
# cap) must not open a second tool_use block.
structured = [
{
"index": 0,
"id": "call_up",
"function": {"name": "lookup", "arguments": "{}"},
}
]
events = self._events(
self._emitter(disable_parallel_tool_use = True),
[
self._chunk(content = LOOKUP_XML),
self._chunk(tool_calls = structured),
self._chunk(finish_reason = "tool_calls"),
],
)
starts = [
e
for e in events
if e.get("type") == "content_block_start" and e["content_block"]["type"] == "tool_use"
]
assert len(starts) == 1
def test_no_healing_means_verbatim_text(self):
from core.inference.anthropic_compat import AnthropicPassthroughEmitter
emitter = AnthropicPassthroughEmitter() # enable_healing never called
events = self._events(
emitter,
[self._chunk(content = LOOKUP_XML), self._chunk(finish_reason = "stop")],
)
texts = [
e["delta"]["text"]
for e in events
if e.get("type") == "content_block_delta" and e["delta"]["type"] == "text_delta"
]
assert "".join(texts) == LOOKUP_XML
class TestAnthropicNonStreamingRoute:
async def _drive(
self,
monkeypatch,
bodies,
auto_heal = None,
tools = None,
tool_choice = "auto",
):
import routes.inference as inf_mod
from routes.inference import _anthropic_passthrough_non_streaming
client = ScriptedClient(bodies)
monkeypatch.setattr(inf_mod, "nonstreaming_client", lambda: client)
response = await _anthropic_passthrough_non_streaming(
_llama_backend(),
[{"role": "user", "content": "hi"}],
tools if tools is not None else [LOOKUP_TOOL],
0.7,
0.95,
None,
256,
"msg_test",
"gguf",
tool_choice = tool_choice,
auto_heal_tool_calls = auto_heal,
)
return client, json.loads(response.body)
def test_promotes_xml_to_tool_use(self, monkeypatch):
async def _run():
_, data = await self._drive(monkeypatch, [_upstream_message(LOOKUP_XML)])
(block,) = [b for b in data["content"] if b["type"] == "tool_use"]
assert block["name"] == "lookup"
assert block["input"] == {"q": "x"}
assert data["stop_reason"] == "tool_use"
assert not any(b["type"] == "text" for b in data["content"])
asyncio.run(_run())
def test_opt_out_keeps_legacy_strip(self, monkeypatch):
async def _run():
_, data = await self._drive(
monkeypatch, [_upstream_message(f"plan {LOOKUP_XML}")], auto_heal = False
)
assert data["stop_reason"] == "end_turn"
(block,) = data["content"]
assert block["type"] == "text"
assert block["text"] == "plan" # XML stripped, nothing promoted
asyncio.run(_run())
def test_undeclared_tool_not_promoted(self, monkeypatch):
async def _run():
xml = '<tool_call>{"name":"rogue","arguments":{}}</tool_call>'
_, data = await self._drive(monkeypatch, [_upstream_message(xml)])
assert data["stop_reason"] == "end_turn"
assert not any(b["type"] == "tool_use" for b in data["content"])
# Healing preserves what it does not promote: the undeclared call
# reaches the client as text instead of being silently stripped.
(text_block,) = [b for b in data["content"] if b["type"] == "text"]
assert text_block["text"] == xml
asyncio.run(_run())
def test_mixed_undeclared_text_preserved_after_heal(self, monkeypatch):
async def _run():
# Declared call promoted to tool_use; the undeclared call's markup
# stays in the text block (the legacy strip must not run after a
# span-exact heal), matching the OpenAI passthrough.
rogue = '<tool_call>{"name":"rogue","arguments":{}}</tool_call>'
_, data = await self._drive(monkeypatch, [_upstream_message(f"{LOOKUP_XML} {rogue}")])
(tool_block,) = [b for b in data["content"] if b["type"] == "tool_use"]
assert tool_block["name"] == "lookup"
(text_block,) = [b for b in data["content"] if b["type"] == "text"]
assert rogue in text_block["text"]
assert data["stop_reason"] == "tool_use"
asyncio.run(_run())
def test_length_beats_tool_use(self, monkeypatch):
async def _run():
_, data = await self._drive(
monkeypatch, [_upstream_message(LOOKUP_XML, finish_reason = "length")]
)
assert data["stop_reason"] == "max_tokens"
assert any(b["type"] == "tool_use" for b in data["content"])
asyncio.run(_run())
def test_tool_choice_none_keeps_legacy_strip(self, monkeypatch):
async def _run():
# Anthropic {"type": "none"} arrives here converted to "none":
# the request forbade tool calls, so nothing is promoted and the
# legacy XML strip applies as before healing existed.
_, data = await self._drive(
monkeypatch,
[_upstream_message(f"plan {LOOKUP_XML}")],
tool_choice = "none",
)
assert data["stop_reason"] == "end_turn"
(block,) = data["content"]
assert block["type"] == "text"
assert block["text"] == "plan"
asyncio.run(_run())
class TestOpenaiStreamingRoute:
def test_heals_streamed_xml(self, monkeypatch):
async def _run():
pieces = ["<tool_call>", '{"name":"lookup",', '"arguments":{"q":"x"}}', "</tool_call>"]
lines = [
'data: {"id":"c1","model":"gguf","created":1,"choices":[{"index":0,"delta":{"content":%s}}]}'
% json.dumps(p)
for p in pieces
]
lines += [
'data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
"data: [DONE]",
]
chunks = await _drive_stream(monkeypatch, _payload(), lines)
payloads = _stream_payloads(chunks)
tool_deltas = [
tc
for p in payloads
for c in p.get("choices", [])
for tc in (c.get("delta") or {}).get("tool_calls") or []
]
(call,) = tool_deltas
assert call["function"]["name"] == "lookup"
assert json.loads(call["function"]["arguments"]) == {"q": "x"}
finishes = [
c["finish_reason"]
for p in payloads
for c in p.get("choices", [])
if c.get("finish_reason")
]
assert finishes == ["tool_calls"]
# None of the XML leaked as visible content.
text = "".join(
(c.get("delta") or {}).get("content") or ""
for p in payloads
for c in p.get("choices", [])
)
assert "<tool_call>" not in text
assert chunks[-1] == "data: [DONE]\n\n"
asyncio.run(_run())
def test_parallel_cap_drops_native_after_healed(self, monkeypatch):
async def _run():
# parallel_tool_calls=false: a healed call consumed the single
# allowed slot, and the upstream SSE cap keeps native index 0, so
# the route must drop the later native call itself.
xml = '<tool_call>{"name":"lookup","arguments":{"q":"x"}}</tool_call>'
native = (
'data: {"id":"c1","choices":[{"index":0,"delta":{"tool_calls":'
'[{"index":0,"id":"call_up","type":"function","function":'
'{"name":"lookup","arguments":"{}"}}]}}]}'
)
lines = [
'data: {"id":"c1","model":"gguf","created":1,"choices":'
'[{"index":0,"delta":{"content":%s}}]}' % json.dumps(xml),
native,
'data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}',
"data: [DONE]",
]
chunks = await _drive_stream(monkeypatch, _payload(parallel_tool_calls = False), lines)
payloads = _stream_payloads(chunks)
tool_deltas = [
tc
for p in payloads
for c in p.get("choices", [])
for tc in (c.get("delta") or {}).get("tool_calls") or []
]
(call,) = tool_deltas
assert call["id"] == "call_0" # the healed call; native was dropped
asyncio.run(_run())
def test_false_alarm_text_flushes(self, monkeypatch):
async def _run():
lines = [
'data: {"id":"c1","choices":[{"index":0,"delta":{"content":"use the <div> tag"}}]}',
'data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
"data: [DONE]",
]
chunks = await _drive_stream(monkeypatch, _payload(), lines)
payloads = _stream_payloads(chunks)
text = "".join(
(c.get("delta") or {}).get("content") or ""
for p in payloads
for c in p.get("choices", [])
)
assert text == "use the <div> tag"
finishes = [
c["finish_reason"]
for p in payloads
for c in p.get("choices", [])
if c.get("finish_reason")
]
assert finishes == ["stop"]
asyncio.run(_run())
def test_incomplete_xml_healed_at_done(self, monkeypatch):
async def _run():
# No close tag and no finish chunk: healed at the [DONE] boundary,
# synthetic finish must say tool_calls.
lines = [
'data: {"id":"c1","choices":[{"index":0,"delta":{"content":"<tool_call>{\\"name\\":\\"lookup\\",\\"arguments\\":{}}"}}]}',
"data: [DONE]",
]
chunks = await _drive_stream(monkeypatch, _payload(), lines)
payloads = _stream_payloads(chunks)
tool_deltas = [
tc
for p in payloads
for c in p.get("choices", [])
for tc in (c.get("delta") or {}).get("tool_calls") or []
]
assert len(tool_deltas) == 1
finishes = [
c["finish_reason"]
for p in payloads
for c in p.get("choices", [])
if c.get("finish_reason")
]
assert finishes == ["tool_calls"]
asyncio.run(_run())
def test_structured_upstream_calls_relay_verbatim(self, monkeypatch):
async def _run():
line = (
'data: {"id":"c1","choices":[{"index":0,"delta":{"tool_calls":'
'[{"index":0,"id":"call_up","type":"function","function":'
'{"name":"lookup","arguments":"{}"}}]}}]}'
)
lines = [
line,
'data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}',
"data: [DONE]",
]
chunks = await _drive_stream(monkeypatch, _payload(), lines)
assert chunks[0] == line + "\n\n" # byte-for-byte relay
asyncio.run(_run())
class TestHealerSignalAlignment:
"""The passthrough healer buffers only formats its parser can promote.
The loops' bare [ARGS] rehearsal signal is gated on active tool names
there; ungated in the healer it would stall legitimate prose until
finalization without ever producing a promotable call."""
def test_heal_signals_are_promotable_formats_only(self):
from core.inference.passthrough_healing import _HEAL_SIGNALS
assert set(_HEAL_SIGNALS) == {
"<tool_call>",
"<|tool_call>",
"<function=",
"[TOOL_CALLS]",
}
def test_prose_with_bare_args_marker_streams_through(self):
healer = StreamToolCallHealer({"Bash"})
chunks = [
"Use the pattern foo",
"[ARGS] in templates when calling tools, ",
"and remember to close it.",
]
streamed = ""
for chunk in chunks:
streamed += _events_text(healer.feed(chunk))
# Incremental relay: nothing withheld for finalize.
assert streamed == "".join(chunks)
final = healer.finalize()
assert not _events_calls(final)
assert not healer.healed
def test_bracket_tool_calls_still_promote_in_stream(self):
healer = StreamToolCallHealer({"web_search"})
events = healer.feed('[TOOL_CALLS]web_search{"query": "unsloth docs"}') + healer.finalize()
(call,) = _events_calls(events)
assert call["function"]["name"] == "web_search"
assert healer.healed