Skip to content
23 changes: 19 additions & 4 deletions anton/core/backends/scratchpad_boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ def generate_object(
"""
from anton.core.llm.structured import (
build_structured_tool,
looks_truncated,
raise_unusable_tool_call,
unwrap_structured_response,
)

Expand All @@ -233,11 +235,24 @@ def generate_object(
)

if not response.tool_calls:
raise ValueError("LLM did not return structured output.")
# Same classification as the async path (ENG-1081).
# Nothing retries here, but the message reaches the model as
# a traceback, so "you ran out of budget" is actionable
# where "did not return structured output" was not.
raise_unusable_tool_call(
response, tool_name=tool["name"], budget=max_tokens
)

return unwrap_structured_response(
response.tool_calls[0].input, validator_class, is_list
)
try:
return unwrap_structured_response(
response.tool_calls[0].input, validator_class, is_list
)
except Exception:
if looks_truncated(response, max_tokens):
raise_unusable_tool_call(
response, tool_name=tool["name"], budget=max_tokens
)
raise

_scratchpad_llm_instance = _ScratchpadLLM()

Expand Down
34 changes: 25 additions & 9 deletions anton/core/llm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,28 +199,41 @@ async def _generate_object_with(
"""
from anton.core.llm.structured import (
build_structured_tool,
looks_truncated,
raise_unusable_tool_call,
unwrap_structured_response,
)

tool, validator_class, is_list = build_structured_tool(schema_class)

budget = max_tokens or self._max_tokens

response = await provider.complete(
model=model,
system=system,
messages=messages,
tools=[tool],
tool_choice={"type": "tool", "name": tool["name"]},
max_tokens=max_tokens or self._max_tokens,
max_tokens=budget,
)

if not response.tool_calls:
raise ValueError(
f"LLM did not return a tool call for forced schema {tool['name']}."
)
# Shared with the scratchpad's sync twin so both paths classify the
# failure identically — see `raise_unusable_tool_call` (ENG-1081).
raise_unusable_tool_call(response, tool_name=tool["name"], budget=budget)

return unwrap_structured_response(
response.tool_calls[0].input, validator_class, is_list
)
try:
return unwrap_structured_response(
response.tool_calls[0].input, validator_class, is_list
)
except Exception:
# The budget can also run out *inside* the tool call's JSON, so
# validation fails here instead. Same cause, same retry (ENG-1081).
if looks_truncated(response, budget):
raise_unusable_tool_call(
response, tool_name=tool["name"], budget=budget
)
raise

async def generate_object(
self,
Expand Down Expand Up @@ -263,8 +276,11 @@ async def generate_object(
input was a list annotation.

Raises:
ValueError: If the LLM fails to produce a tool call (rare —
forced tool_choice usually prevents this).
StructuredOutputError: If the LLM fails to produce a tool call.
A ``ValueError`` subclass, so callers that catch
``ValueError`` are unaffected. Check ``.truncated`` to tell a
blown ``max_tokens`` budget (retry with more room) from a
genuine failure (ENG-1081).
pydantic.ValidationError: If the tool's input doesn't match
the schema.

Expand Down
30 changes: 30 additions & 0 deletions anton/core/llm/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,36 @@ class TokenLimitExceeded(Exception):
"""Raised when the LLM returns 429 due to billing/token limits."""


class StructuredOutputError(ValueError):
"""Raised when a forced-tool-call structured-output call yields no usable call.

``truncated`` says whether a retry can help: ``True`` means the model spent
the ``max_tokens`` budget narrating before it reached the tool call (the
narrating aliases — ``mindshub_air``/``kimi``, ``deepseek``, ``qwen`` — do
this deterministically under a tight budget, ENG-1081); ``False`` means the
provider errored, refused, or returned nothing, and a bigger budget won't
fix it. See `structured.looks_truncated` for how that's decided.

Subclasses ``ValueError`` so call sites catching the documented ``ValueError``
from ``generate_object``/``generate_object_code`` keep working.
"""

def __init__(
self,
message: str,
*,
truncated: bool = False,
output_tokens: int = 0,
max_tokens: int = 0,
stop_reason: str | None = None,
):
super().__init__(message)
self.truncated = truncated
self.output_tokens = output_tokens
self.max_tokens = max_tokens
self.stop_reason = stop_reason


class TransientProviderError(ConnectionError):
"""Raised when the provider fails in a way that a retry might fix.

Expand Down
84 changes: 83 additions & 1 deletion anton/core/llm/structured.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,89 @@

from __future__ import annotations

from typing import Any
from typing import Any, NoReturn

from .provider import StructuredOutputError


def looks_truncated(response, budget: int) -> bool:
"""True if `response` was cut off by the `max_tokens` budget.

Token count first, because the MindsHub gateway reports
``finish_reason: "stop"`` at the cap for most aliases (ENG-1082) — the
standard ``"length"`` check can't be relied on there. Both dialects are
honoured when reported: OpenAI says ``"length"``, Anthropic ``"max_tokens"``.
No usage information → ``False``; without evidence we don't buy a retry.
"""
usage = getattr(response, "usage", None)
output_tokens = getattr(usage, "output_tokens", 0) or 0
stop_reason = getattr(response, "stop_reason", None)
return stop_reason in ("length", "max_tokens") or (
budget > 0 and output_tokens >= budget
)


def raise_unusable_tool_call(response, *, tool_name: str, budget: int) -> NoReturn:
"""Raise `StructuredOutputError` explaining *why* the tool call is unusable.

Covers both shapes of the same underlying problem:

- **No tool call at all** — the model narrated in plain ``content`` until
the budget ran out.
- **A damaged tool call** — the budget ran out *inside* the call's JSON
arguments, so ``safe_parse_tool_input`` salvaged a partial dict and set
``parse_error``, and validation then fails. Without this, that case
surfaces as a bare ``ValidationError``, which reads like a schema bug and
never gets the retry a truncation deserves (ENG-1081).

Lives here, next to `build_structured_tool`/`unwrap_structured_response`,
because both structured-output paths must classify the failure the same
way — the async `LLMClient._generate_object_with` and the sync
`_ScratchpadLLM.generate_object` in the scratchpad subprocess. Keeping it
in one of the two callers is how they drift (ENG-1081).

A forced tool call can come back empty for two very different reasons:

- **Truncated** — the model narrated in plain ``content`` and ran out of
``budget`` before reaching the call. Retrying with more room usually
works. Models served through MindsHub's Fireworks aliases
(``mindshub_air``/``kimi``, ``deepseek``) narrate before acting, so a
tight budget fails them.
- **Anything else** — the provider errored, refused, or returned nothing.
A bigger budget won't help.

See `looks_truncated` for how truncation is detected.

Args:
response: The provider's ``LLMResponse``.
tool_name: Name of the forced tool, for the message.
budget: The ``max_tokens`` the call was given.

Raises:
StructuredOutputError: Always. ``.truncated`` carries the verdict.
"""
usage = getattr(response, "usage", None)
output_tokens = getattr(usage, "output_tokens", 0) or 0
stop_reason = getattr(response, "stop_reason", None)
truncated = looks_truncated(response, budget)
what = (
"returned an unusable tool call for"
if getattr(response, "tool_calls", None)
else "did not return a tool call for"
)
detail = (
f" (truncated: {output_tokens}/{budget} output tokens spent before the "
"call was complete)."
if truncated
else "."
)
raise StructuredOutputError(
f"LLM {what} forced schema {tool_name}{detail}",
truncated=truncated,
output_tokens=output_tokens,
max_tokens=budget,
stop_reason=stop_reason,
)


def build_structured_tool(schema_class) -> tuple[dict, type, bool]:
Expand Down
137 changes: 126 additions & 11 deletions anton/core/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
StreamTaskProgress,
StreamTextDelta,
StreamToolResult,
StructuredOutputError,
TokenLimitExceeded,
ToolCall,
TransientProviderError,
Expand Down Expand Up @@ -158,6 +159,81 @@ class _VerifierVerdict(BaseModel):
reason: str = Field(description="One brief sentence explaining the verdict.")


# Output budgets for the verdict call: first attempt, then the retry used when
# it comes back truncated (ENG-1081).
#
# Models that narrate before acting (`mindshub_air`/`kimi`, `deepseek`, `qwen`)
# spend the budget on prose and never reach the forced tool call. The original
# 256 truncated them on essentially every call — 98.6% of `mindshub_air` verdicts
# in prod returned no tool call, which the fail-safe below turned into a silent
# "task complete".
#
# 2048 is sized from a measured distribution, not one sample: 16 identical calls
# spanned 245–1654 output tokens (median ~290). That 6.7x per-call spread is also
# why there is no "this model can't do it" latch — one truncation is a tail
# sample, not proof about the next turn — and why the 4096 retry exists rather
# than a single bigger budget. 1024 was measurably too small. Nothing pays for
# headroom it doesn't use; first-party models answer in 43–115 tokens either way.
_VERIFIER_TOKEN_BUDGETS = (2048, 4096)

# Appended to the verifier system prompt. Shortens the preamble on narrating
# models but is not sufficient alone (0/3 at 256 with it), so it pairs with the
# budgets above. Tool name comes off the schema class so it can't go stale.
_VERIFIER_NO_PREAMBLE = (
f"Call the {_VerifierVerdict.__name__} tool immediately as your first "
"action. Do not think out loud, restate the conversation, or explain your "
"reasoning before calling it — put your one-sentence justification in the "
"tool's `reason` field."
)


def _safe_error_detail(exc: BaseException) -> str:
"""Describe an exception for logs without copying model or user content.

Neither ``str(exc)`` nor ``repr(exc)`` is safe here: a Pydantic
``ValidationError`` embeds the rejected ``input_value`` — for the verifier
that's model-generated text derived from the user's conversation — and
provider exceptions can carry response bodies. Emits the exception type,
plus for validation errors the field locations and error codes only, which
is what actually identifies the failure.
"""
# This runs *inside* an `except` handler, so it must not raise: an exception
# escaping here would turn a gracefully-handled verifier failure into a dead
# turn. Everything below is therefore wrapped, including the attribute reads
# (a custom exception can expose `status_code`/`errors` as a property that
# raises).
try:
name = type(exc).__name__
except Exception: # pragma: no cover — defensive
return "unavailable"
try:
status = getattr(exc, "status_code", None)
if status is not None:
return f"{name}(status={status})"
errors = getattr(exc, "errors", None)
if callable(errors):
try:
details = errors(
include_input=False, include_url=False, include_context=False
)
except TypeError: # pydantic v1 has no include_* kwargs
details = errors()
# Only `loc` and `type` are ever read — `msg`/`input`/`ctx` can
# quote the rejected value.
fields = ",".join(
f"{'.'.join(str(p) for p in e.get('loc') or ())}:{e.get('type', '?')}"
for e in details
)
if fields:
return f"{name}({fields})"
except Exception:
# Anything odd about this exception object — a property that raises, an
# `errors()` that misbehaves — degrades to the type name, never a crash
# and never the message.
pass
return name


def _render_tool_result_content(content, cap: int) -> str:
"""Render a tool_result's content as bounded plain text.

Expand Down Expand Up @@ -2656,25 +2732,64 @@ async def _stream_and_handle_tools(
"You are a task-completion verifier. Decide whether the user's "
"request is complete, the assistant is waiting on the user, the work "
"is unfinished, or the assistant is blocked. Follow the status "
"definitions exactly."
"definitions exactly.\n\n"
# Models that narrate before acting spend the whole budget on
# prose and never reach the tool call (ENG-1081). Asking for the
# call first shortens the preamble; it does not eliminate it,
# which is why the budget below is generous as well.
+ _VERIFIER_NO_PREAMBLE
)
try:
verdict = await self._llm.generate_object_code(
_VerifierVerdict,
system=verifier_system,
messages=verify_messages,
max_tokens=256,
)
verdict = None
for attempt, budget in enumerate(_VERIFIER_TOKEN_BUDGETS):
try:
verdict = await self._llm.generate_object_code(
_VerifierVerdict,
system=verifier_system,
messages=verify_messages,
max_tokens=budget,
)
break
except StructuredOutputError as exc:
# A truncated verdict is a budget problem, not a verdict: the
# model narrated past `budget` before it reached the tool call
# (ENG-1081). Retry with more room. Any other structured-output
# failure won't be fixed by a bigger budget, so don't pay for it.
retrying = exc.truncated and attempt + 1 < len(
_VERIFIER_TOKEN_BUDGETS
)
_verifier_log.info(
"completion-verifier verdict=%s budget=%d output_tokens=%d "
"stop_reason=%s retrying=%s",
"TRUNCATED" if exc.truncated else "NO_TOOL_CALL",
budget, exc.output_tokens, exc.stop_reason, retrying,
)
if not retrying:
break
except Exception as exc:
# Enough to tell the failure modes apart — four used to
# collapse into one "verifier unavailable" line — but never
# the exception message, which can carry conversation
# content (ENG-1081).
_verifier_log.info(
"completion-verifier verdict=ERROR budget=%d error=%s",
budget, _safe_error_detail(exc),
)
break

if verdict is not None:
status = verdict.status
reason = verdict.reason.strip()
except Exception:
else:
# Verifier failed — fail safe by treating the turn as done rather
# than forcing a continuation the user never asked for.
status, reason = "COMPLETE", "verifier unavailable"

_verifier_log.info(
"completion-verifier verdict=%s continuation=%d/%d tool_rounds=%d reason=%s",
status, continuation, self._max_continuations, tool_round, reason,
# No `reason` — it's the model's free-text justification, derived
# from the user's conversation, and this is an ordinary app log.
# It stays in the Langfuse trace, where that content belongs.
"completion-verifier verdict=%s continuation=%d/%d tool_rounds=%d",
status, continuation, self._max_continuations, tool_round,
)

if status in ("COMPLETE", "WAITING"):
Expand Down
Loading
Loading