Skip to content

Commit a02944a

Browse files
fix(llamafile): harden the malformed-500 rescue gate and drop skeleton calls (#128)
* fix(llamafile): drop skeleton tool calls in rescue to stop tool-error exhaustion The malformed-tool-call 500 rescue shim was lenient: it returned every parseable <tool_call> block, including skeleton/preview blocks missing required parameters. On a write-stutter (a skeleton block immediately followed by the complete call), both were returned; the skeleton was dispatched, raised [ToolError] on the tool channel, and the model retried into consecutive-tool-error exhaustion (observed on rig-04). _rescue_tool_calls now filters on `required.issubset(args)`, returning only complete calls. The write-stutter yields the complete call alone; a skeleton-only body yields no calls, so the caller falls through to the _STUTTER_RETRY_TEXT nudge (bounded by the retry budget upstream) instead of a broken dispatched call. Malformed args are still kept raw (not dropped) to match the native path — a garbled optional param must not suppress an otherwise-complete call. Also close three raw_body leaks: the fallback BackendError(500, ...) calls in send_stream, _send_native, and the "no choices" path passed the raw backend body positionally as `detail`, landing it in the logged/returned message; they now pass it as raw_body= (kept on exc.body). Tests: rewrite the three tests that locked in the lenient behavior (incl. inverting skeleton-only to expect the nudge), add native coverage for no-required-params and multi-call cases, and add streaming-path rescue tests (previously zero) covering the stutter, skeleton-only, no-blocks, unknown-tool, multi-line body reassembly, and arbitrary-500 cascade. 110 llamafile tests pass; full unit suite 1386 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CokRDPXHYQ4c65MUfdjTAG * fix(llamafile): gate rescue on structural tool-call XML, not the error phrase The malformed-tool-call 500 gate was pinned to the literal llama.cpp string "Failed to parse input". Later llama.cpp builds rename that message (e.g. beac5309f -> "...does not match the expected ... format"), so the gate silently stopped matching on newer backends and a rescuable malformed call cascaded as a hard BackendError instead. Gate on the structural signal instead: a 500 is a rescuable tool-call artifact iff its body carries generated tool-call XML (<tool_call>/<function=). Only the model generating such a call and the backend choking on it puts that syntax in a 500 body — genuine faults (OOM/slot/context/CUDA) never echo generation, and a request-parse failure dumps JSON schema, not this XML (and so still cascades). This is robust to every llama.cpp error-string rename. rescue itself only returns calls for full <tool_call>...<function=NAME>... blocks naming a tool in the request, so a broad gate never fabricates a call. Shared helper, so both the streaming and native paths broaden together. Tests: add phrase-independence coverage (a renamed-phrase body embedding a complete block still rescues, native + streaming twin; renamed-phrase skeleton still nudges) and a tools-schema-dump-cascades test documenting that a request-parse 500 carries no XML token and therefore cascades. 114 llamafile tests pass; full unit suite 1390 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CokRDPXHYQ4c65MUfdjTAG * docs(llamafile): record llama.cpp version boundary for the malformed-500 rescue The structural gate in _is_malformed_tool_call_500 depends on llama.cpp echoing the raw rejected generation into the 500 response body. That behavior ended at llama.cpp commit 581e8eca8 ("chat: harden peg-native tool call parsing", PR #24329), first shipped in tag b9656 (2026-06-15): the parse-failure exception became a generic "does not match the expected <format> format" and the raw generation moved to server logs only. On b9656+ the gate can never match (nothing to rescue from the body), and the grammar hardening in the same series makes the malformed call rare anyway, so the 500 just cascades. The rescue is therefore effective only on builds <= b9647. Correct the prior "robust to every error-string rename" comment, which missed that the rename bundled removal of the raw body. Comment-only; no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(llamafile): match truncated tool-call open tags in the rescue gate An echo cut off mid-open-tag (EOS or token budget landing inside "<tool_call") is the same re-sampleable artifact as a complete block; gate on the unclosed prefix so it returns the generic retry nudge instead of cascading as BackendError. Pin the b9656+ no-echo generic parse-500 as a cascade on both send() and send_stream(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f7749df commit a02944a

2 files changed

Lines changed: 474 additions & 53 deletions

File tree

src/forge/clients/llamafile.py

Lines changed: 62 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,27 @@
4848
# — NOT an arbitrary backend error. This one is a transient sampling artifact and
4949
# recoverable by re-sampling, so we surface it as a retryable text response (the
5050
# run_inference retry loop nudges the model to re-emit a clean call) instead of
51-
# echoing the raw error JSON into the conversation. Every OTHER 500 cascades as a
52-
# BackendError.
51+
# echoing the raw error JSON into the conversation. Every OTHER 500 cascades.
52+
#
53+
# The gate is STRUCTURAL, not phrase-based: a 500 is rescuable iff its body
54+
# carries generated tool-call XML (``<tool_call``/``<function=``). Only the
55+
# model generating such a call and the backend choking on it puts that syntax in
56+
# a 500 body — genuine faults (OOM/slot/context/CUDA) never echo generation, and
57+
# a request-parse failure dumps JSON, not this XML. ``<tool_call`` is
58+
# deliberately unclosed: an echo truncated mid-open-tag (EOS or token budget
59+
# landing inside the tag) is the same re-sampleable artifact as a complete one,
60+
# and the leading ``<`` keeps the gate structural — request and schema dumps
61+
# carry ``"tool_calls"`` as quoted JSON, never the tag.
62+
#
63+
# VERSION BOUNDARY: rescue only fires on llama.cpp builds that echo the raw
64+
# rejected generation into the 500 body. That ended at commit 581e8eca8
65+
# ("chat: harden peg-native tool call parsing", PR #24329, first in tag b9656,
66+
# 2026-06-15): the exception became a generic "...does not match the expected
67+
# <format> format" and the raw generation moved to server logs only. On b9656+
68+
# this gate never matches, and grammar hardening in the same series makes the
69+
# malformed call rare anyway, so the 500 just cascades. Effective only on <= b9647.
5370
def _is_malformed_tool_call_500(body: str) -> bool:
54-
return "Failed to parse input" in body and ("tool_call" in body or "<function=" in body)
71+
return "<tool_call" in body or "<function=" in body
5572

5673

5774
_MALFORMED_TOOL_CALL_RETRY_TEXT = (
@@ -60,12 +77,12 @@ def _is_malformed_tool_call_500(body: str) -> bool:
6077
"single, complete, well-formed tool call.)"
6178
)
6279

63-
# Used only when the 500 body demonstrably contains <tool_call> block(s) but
64-
# none re-parse to a tool from the request (mangled syntax or unknown tool
65-
# names) — i.e. we KNOW the model emitted a defective call block. A
80+
# Used when the 500 body demonstrably contains <tool_call> block(s) but none
81+
# survive rescue — either mangled syntax / unknown tool names, or (the common
82+
# case) skeleton/preview blocks dropped for missing a required parameter. A
6683
# malformed-500 without visible blocks keeps the generic text above; we don't
67-
# name a stutter we haven't seen. Parseable blocks — skeletons included — are
68-
# returned as real ToolCalls instead (see _rescue_tool_calls).
84+
# name a stutter we haven't seen. Complete blocks are returned as real
85+
# ToolCalls and never reach this text (see _rescue_tool_calls).
6986
_STUTTER_RETRY_TEXT = (
7087
"(The previous response contained a malformed tool-call block — a "
7188
"preview/skeleton call missing its required parameters, or a duplicated "
@@ -135,20 +152,25 @@ def _rescue_tool_calls(
135152
error_body: str,
136153
tools_array: list[dict[str, Any]] | None,
137154
) -> tuple[list[ToolCall], bool]:
138-
"""Leniently re-parse tool calls out of a malformed-tool-call 500 body.
139-
140-
Returns ``(calls, saw_blocks)``. Every block that parses and names a tool
141-
present in the request's ``tools`` array is returned — including
142-
skeleton/preview blocks missing required parameters. Validity is decided
143-
downstream: the ResponseValidator and the runner's ``fn(**args)`` dispatch
144-
reject a skeleton with a ``[ToolError] TypeError`` on the tool channel
145-
(the canonical corrective signal), while complete calls simply execute.
146-
Unknown tool names are never returned (nothing we can't check against the
155+
"""Re-parse the COMPLETE tool calls out of a malformed-tool-call 500 body.
156+
157+
Returns ``(calls, saw_blocks)``. A block is returned only when it parses,
158+
names a tool present in the request's ``tools`` array, AND carries every
159+
parameter that tool marks required. Skeleton/preview blocks missing a
160+
required param are DROPPED — dispatching them would raise a
161+
``[ToolError] TypeError`` on the tool channel and, in the write-stutter
162+
case (skeleton immediately followed by the complete call), drive the model
163+
into consecutive-tool-error exhaustion. The complete call from that same
164+
stutter survives the filter and is returned alone; a skeleton-only body
165+
yields no calls, so the caller falls through to a clean re-emit nudge.
166+
167+
Unknown tool names are never returned (nothing we can check against the
147168
request), exact duplicates are deduped, and param values are coerced to
148169
their declared schema types where possible (kept as raw strings when
149-
coercion fails — the tool decides). ``saw_blocks`` reports whether any
150-
``<tool_call>`` block was visible at all — it selects the
151-
stutter-specific retry text when parsing comes up empty.
170+
coercion fails, matching the native path — a present-but-malformed arg
171+
still satisfies the required-set check and the tool decides). ``saw_blocks``
172+
reports whether any ``<tool_call>`` block was visible at all — it selects
173+
the stutter-specific retry text when parsing comes up empty.
152174
"""
153175
try:
154176
message = json.loads(error_body).get("error", {}).get("message", "")
@@ -165,13 +187,15 @@ def _rescue_tool_calls(
165187
spec = requirements.get(name)
166188
if spec is None:
167189
continue # unknown tool — never fabricate a call we can't check
168-
_required, types = spec
190+
required, types = spec
169191
args: dict[str, Any] = {}
170192
for key, raw_value in _TOOL_PARAM_RE.findall(params_text):
171193
try:
172194
args[key] = _coerce_param(raw_value, types.get(key))
173195
except ValueError:
174196
args[key] = raw_value
197+
if not required.issubset(args):
198+
continue # skeleton/preview block missing a required param — drop it
175199
dedupe_key = (name, json.dumps(args, sort_keys=True, default=str))
176200
if dedupe_key in seen:
177201
continue
@@ -560,10 +584,10 @@ async def send_stream(
560584
async for line in response.aiter_lines():
561585
error_body += line
562586
if _is_malformed_tool_call_500(error_body):
563-
# Leniently re-parse the model's calls out of the 500 body
564-
# and hand them downstream: complete calls execute,
565-
# skeletons get rejected by dispatch as [ToolError] on the
566-
# tool channel.
587+
# Re-parse the model's COMPLETE calls out of the 500 body
588+
# and hand them downstream to execute; skeletons missing a
589+
# required param are dropped inside _rescue_tool_calls so
590+
# they never reach dispatch.
567591
calls, saw_blocks = _rescue_tool_calls(
568592
error_body, body.get("tools")
569593
)
@@ -586,8 +610,8 @@ async def send_stream(
586610
),
587611
)
588612
return
589-
# Arbitrary backend 500 — cascade.
590-
raise BackendError(500, error_body)
613+
# Arbitrary backend 500 — cascade. Raw body off the message.
614+
raise BackendError(500, raw_body=error_body)
591615
async for line in response.aiter_lines():
592616
line = line.strip()
593617
if not line or not line.startswith("data: "):
@@ -757,9 +781,10 @@ async def _send_native(
757781
if resp.status_code == 500:
758782
is_parse = _is_malformed_tool_call_500(resp.text)
759783
if is_parse:
760-
# Leniently re-parse the model's calls out of the 500 body and
761-
# hand them downstream: complete calls execute, skeletons get
762-
# rejected by dispatch as [ToolError] on the tool channel.
784+
# Re-parse the model's COMPLETE calls out of the 500 body and
785+
# hand them downstream to execute; skeletons missing a required
786+
# param are dropped inside _rescue_tool_calls so they never
787+
# reach dispatch.
763788
calls, saw_blocks = _rescue_tool_calls(resp.text, body.get("tools"))
764789
if calls:
765790
self.rescued_tool_calls += len(calls)
@@ -775,16 +800,20 @@ async def _send_native(
775800
content=_STUTTER_RETRY_TEXT if saw_blocks
776801
else _MALFORMED_TOOL_CALL_RETRY_TEXT
777802
)
778-
# Arbitrary backend 500 — cascade.
779-
raise BackendError(500, resp.text)
803+
# Arbitrary backend 500 — cascade. Raw body off the message.
804+
raise BackendError(500, raw_body=resp.text)
780805
if resp.status_code != 200:
781806
raise BackendError(resp.status_code, raw_body=resp.text)
782807
data = resp.json()
783808
self._record_usage(data)
784809

785810
choices = data.get("choices") or []
786811
if not choices:
787-
raise BackendError(500, f"response has no choices: {data}")
812+
# Raw envelope off the message (a gateway could echo a credential
813+
# into a 200 body too); kept on exc.body for debugging.
814+
raise BackendError(
815+
500, "response has no choices", raw_body=json.dumps(data, default=str)
816+
)
788817
choice = choices[0].get("message", {})
789818
raw_tool_calls = choice.get("tool_calls")
790819
if raw_tool_calls:

0 commit comments

Comments
 (0)