Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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