Skip to content

Commit 2ca82d6

Browse files
Entrpiklieret
authored andcommitted
Handle reasoning truncation distinctly from a missing tool call
Builds on #821 (persist the full response on FormatError). When a tool-calling model returns no tool call, distinguish *truncation* -- it ran out of completion budget mid-output -- from a model that genuinely ended its turn without acting. A reasoning model can spend its entire max_tokens budget on reasoning_content and never reach the tool-call emission. The response then has finish_reason="length" (cut mid-output) or "tool_calls" with an empty payload (cut at the boundary). The default retry tells the model "No tool calls found ... you MUST include a tool call", which is misleading -- it didn't forget, it was cut off. - LitellmModel: when the FormatError response is truncated (detected via finish_reason), swap the retry for a truncation-aware message (new truncation_error_template) and flag extra.truncated=True, plus a distinct warning log suggesting a higher max_tokens. Genuine no-tool-call turns (finish_reason=stop) keep the existing message. The response is still persisted on the error per #821. - DefaultAgent: add max_consecutive_format_errors (0 = off). After that many no-tool-call / truncation turns in a row, stop cleanly with exit_status=RepeatedFormatError instead of looping until the budget is gone; a successful tool call resets the counter. Tests: truncation (finish_reason=length and empty tool_calls payload) vs genuine no-tool-call (finish_reason=stop); repeated-error termination and counter reset.
1 parent a85bf5e commit 2ca82d6

4 files changed

Lines changed: 158 additions & 1 deletion

File tree

src/minisweagent/agents/default.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from pydantic import BaseModel
1313

1414
from minisweagent import Environment, Model, __version__
15-
from minisweagent.exceptions import InterruptAgentFlow, LimitsExceeded, TimeExceeded
15+
from minisweagent.exceptions import FormatError, InterruptAgentFlow, LimitsExceeded, TimeExceeded
1616
from minisweagent.utils.serialize import recursive_merge
1717

1818

@@ -29,6 +29,10 @@ class AgentConfig(BaseModel):
2929
"""Stop agent after exceeding (!) this cost."""
3030
wall_time_limit_seconds: int = 0
3131
"""Stop agent after this many seconds of wall-clock time. 0 means no limit."""
32+
max_consecutive_format_errors: int = 0
33+
"""Stop the agent after this many format errors in a row (e.g. repeated max_tokens truncations
34+
that never produce a tool call). 0 means no limit. Guards against a model burning the whole
35+
budget in a bounce loop; exits cleanly with exit_status=RepeatedFormatError."""
3236
output_path: Path | None = None
3337
"""Save the trajectory to this path."""
3438

@@ -44,6 +48,7 @@ def __init__(self, model: Model, env: Environment, *, config_class: type = Agent
4448
self.logger = logging.getLogger("agent")
4549
self.cost = 0.0
4650
self.n_calls = 0
51+
self._consecutive_format_errors = 0
4752
self._start_time = time.time()
4853

4954
def get_template_vars(self, **kwargs) -> dict:
@@ -93,11 +98,26 @@ def run(self, task: str = "", **kwargs) -> dict:
9398
while True:
9499
try:
95100
self.step()
101+
except FormatError as e:
102+
self.add_messages(*e.messages)
103+
self._consecutive_format_errors += 1
104+
if 0 < self.config.max_consecutive_format_errors <= self._consecutive_format_errors:
105+
# Too many no-tool-call / truncation turns in a row: stop cleanly instead of
106+
# looping until the budget is gone.
107+
self.add_messages(
108+
{
109+
"role": "exit",
110+
"content": "RepeatedFormatError",
111+
"extra": {"exit_status": "RepeatedFormatError", "submission": ""},
112+
}
113+
)
96114
except InterruptAgentFlow as e:
97115
self.add_messages(*e.messages)
98116
except Exception as e:
99117
self.handle_uncaught_exception(e)
100118
raise
119+
else:
120+
self._consecutive_format_errors = 0 # a step completed without a format error
101121
finally:
102122
self.save(self.config.output_path)
103123
if self.messages[-1].get("role") == "exit":

src/minisweagent/models/litellm_model.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Any, Literal
88

99
import litellm
10+
from jinja2 import StrictUndefined, Template
1011
from pydantic import BaseModel
1112

1213
from minisweagent.exceptions import FormatError
@@ -37,6 +38,13 @@ class LitellmModelConfig(BaseModel):
3738
"""Cost tracking mode for this model. Can be "default" or "ignore_errors" (ignore errors/missing cost info)"""
3839
format_error_template: str = "{{ error }}"
3940
"""Template used when the LM's output is not in the expected format."""
41+
truncation_error_template: str = (
42+
"Your previous response reached the output token limit (finish_reason={{ finish_reason }}) "
43+
"before you produced a tool call, so it was cut off. Respond more concisely and finish with "
44+
"exactly one tool call. If you need to think more, do so briefly."
45+
)
46+
"""Template used when a response is truncated at max_tokens before a tool call is produced.
47+
Distinct from format_error_template so the model is told it was cut off (not that it forgot)."""
4048
observation_template: str = (
4149
"{% if output.exception_info %}<exception>{{output.exception_info}}</exception>\n{% endif %}"
4250
"<returncode>{{output.returncode}}</returncode>\n<output>\n{{output.output}}</output>"
@@ -94,6 +102,20 @@ def query(self, messages: list[dict[str, str]], **kwargs) -> dict:
94102
# model_dump failed (e.g. unserializable object); fall back to repr
95103
# so the spec contract ("response MUST be persisted") holds unconditionally.
96104
e.messages[0]["extra"]["response"] = repr(response)
105+
# If the response was cut off at the output token limit before a tool call (common with
106+
# reasoning models whose thinking can consume the whole max_tokens budget), tell the
107+
# model it was truncated instead of the misleading default "no tool call found" retry.
108+
if self._is_truncated(response):
109+
finish_reason = response.choices[0].finish_reason
110+
e.messages[0]["content"] = Template(
111+
self.config.truncation_error_template, undefined=StrictUndefined
112+
).render(finish_reason=finish_reason)
113+
e.messages[0]["extra"]["truncated"] = True
114+
logger.warning(
115+
"Model response truncated at the output token limit (finish_reason=%s) "
116+
"before a tool call -- consider raising max_tokens.",
117+
finish_reason,
118+
)
97119
raise
98120
message = response.choices[0].message.model_dump()
99121
message["extra"] = {
@@ -129,6 +151,22 @@ def _parse_actions(self, response) -> list[dict]:
129151
tool_calls = response.choices[0].message.tool_calls or []
130152
return parse_toolcall_actions(tool_calls, format_error_template=self.config.format_error_template)
131153

154+
@staticmethod
155+
def _is_truncated(response) -> bool:
156+
"""Whether a no-tool-call response was cut off at the output token limit.
157+
158+
``length`` means the completion was truncated mid-output; ``tool_calls`` with an empty
159+
payload means it was cut right at the tool-call boundary. Both are budget truncations,
160+
distinct from a model that simply ended its turn (``stop``/``end_turn``) without acting.
161+
"""
162+
try:
163+
choice = response.choices[0]
164+
finish_reason = choice.finish_reason
165+
has_tool_calls = bool(choice.message.tool_calls)
166+
except (AttributeError, IndexError):
167+
return False
168+
return finish_reason == "length" or (finish_reason == "tool_calls" and not has_tool_calls)
169+
132170
def format_message(self, **kwargs) -> dict:
133171
return expand_multimodal_content(kwargs, pattern=self.config.multimodal_regex)
134172

tests/agents/test_default.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from minisweagent.agents.default import DefaultAgent
77
from minisweagent.environments.local import LocalEnvironment
8+
from minisweagent.exceptions import FormatError
89
from minisweagent.models.test_models import (
910
DeterministicModel,
1011
DeterministicResponseAPIToolcallModel,
@@ -456,3 +457,55 @@ def test_empty_actions_handling(model_factory):
456457
assert info["exit_status"] == "Submitted"
457458
assert info["submission"] == "done\n"
458459
assert agent.n_calls == 2
460+
461+
462+
class _FlakyToolcallModel(DeterministicToolcallModel):
463+
"""Like DeterministicToolcallModel, but raises FormatError (as the real LitellmModel now does
464+
on a truncated / no-tool-call turn) for any output marked {"_format_error": True}."""
465+
466+
def query(self, messages, **kwargs):
467+
self.current_index += 1
468+
output = self.config.outputs[self.current_index]
469+
if output.get("_format_error"):
470+
raise FormatError(
471+
{
472+
"role": "user",
473+
"content": "No tool calls found in the response.",
474+
"extra": {"interrupt_type": "FormatError"},
475+
}
476+
)
477+
return output
478+
479+
480+
def test_repeated_format_errors_terminate_cleanly(toolcall_config):
481+
"""With max_consecutive_format_errors set, a run that keeps producing no-tool-call / truncation
482+
turns stops cleanly with exit_status=RepeatedFormatError instead of looping until the budget is
483+
gone."""
484+
outputs = [{"_format_error": True} for _ in range(5)]
485+
agent = DefaultAgent(
486+
model=_FlakyToolcallModel(outputs=outputs),
487+
env=LocalEnvironment(),
488+
**{**toolcall_config, "max_consecutive_format_errors": 2},
489+
)
490+
info = agent.run("Test repeated format errors")
491+
assert info["exit_status"] == "RepeatedFormatError"
492+
assert agent.n_calls == 2 # stopped at the 2nd consecutive error, didn't burn all 5
493+
494+
495+
def test_format_error_counter_resets_on_success(toolcall_config):
496+
"""A successful tool call between format errors resets the consecutive counter, so isolated
497+
errors don't accumulate to the termination threshold."""
498+
good = make_tc_model([("listing", [{"command": "echo hello"}])]).config.outputs[0]
499+
submit = make_tc_model(
500+
[("done", [{"command": "echo 'COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT'\necho ok"}])]
501+
).config.outputs[0]
502+
# error, success (reset), error, submit -> never 2 in a row, so it must NOT terminate early.
503+
outputs = [{"_format_error": True}, good, {"_format_error": True}, submit]
504+
agent = DefaultAgent(
505+
model=_FlakyToolcallModel(outputs=outputs),
506+
env=LocalEnvironment(),
507+
**{**toolcall_config, "max_consecutive_format_errors": 2},
508+
)
509+
info = agent.run("Test counter reset")
510+
assert info["exit_status"] == "Submitted"
511+
assert info["submission"] == "ok\n"

tests/models/test_litellm_model.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,52 @@ def test_parse_actions_no_tool_calls_raises(self, mock_cost, mock_completion):
6262
with pytest.raises(FormatError):
6363
model.query([{"role": "user", "content": "test"}])
6464

65+
@patch("minisweagent.models.litellm_model.litellm.completion")
66+
@patch("minisweagent.models.litellm_model.litellm.cost_calculator.completion_cost")
67+
def test_truncation_finish_length_uses_truncation_message(self, mock_cost, mock_completion):
68+
"""finish_reason=length (cut off before a tool call) -> a truncation-aware retry, flagged."""
69+
response = _mock_litellm_response(None)
70+
response.choices[0].finish_reason = "length"
71+
mock_completion.return_value = response
72+
mock_cost.return_value = 0.001
73+
74+
with pytest.raises(FormatError) as exc:
75+
LitellmModel(model_name="gpt-4").query([{"role": "user", "content": "test"}])
76+
77+
msg = exc.value.messages[0]
78+
assert msg["extra"]["truncated"] is True
79+
assert "cut off" in msg["content"] and "token limit" in msg["content"]
80+
assert "No tool calls found" not in msg["content"] # not the misleading "you forgot" message
81+
82+
@patch("minisweagent.models.litellm_model.litellm.completion")
83+
@patch("minisweagent.models.litellm_model.litellm.cost_calculator.completion_cost")
84+
def test_truncation_empty_toolcalls_payload_is_truncation(self, mock_cost, mock_completion):
85+
"""finish_reason=tool_calls but an empty payload = cut at the tool-call boundary."""
86+
response = _mock_litellm_response(None)
87+
response.choices[0].finish_reason = "tool_calls"
88+
mock_completion.return_value = response
89+
mock_cost.return_value = 0.001
90+
91+
with pytest.raises(FormatError) as exc:
92+
LitellmModel(model_name="gpt-4").query([{"role": "user", "content": "test"}])
93+
assert exc.value.messages[0]["extra"]["truncated"] is True
94+
assert "cut off" in exc.value.messages[0]["content"]
95+
96+
@patch("minisweagent.models.litellm_model.litellm.completion")
97+
@patch("minisweagent.models.litellm_model.litellm.cost_calculator.completion_cost")
98+
def test_genuine_no_tool_call_is_not_truncation(self, mock_cost, mock_completion):
99+
"""finish_reason=stop with no tool call = the model really ended its turn without acting;
100+
keep the normal 'no tool calls found' retry and do NOT flag truncation."""
101+
response = _mock_litellm_response(None)
102+
response.choices[0].finish_reason = "stop"
103+
mock_completion.return_value = response
104+
mock_cost.return_value = 0.001
105+
106+
with pytest.raises(FormatError) as exc:
107+
LitellmModel(model_name="gpt-4").query([{"role": "user", "content": "test"}])
108+
assert "truncated" not in exc.value.messages[0]["extra"]
109+
assert "No tool calls found" in exc.value.messages[0]["content"]
110+
65111
def test_format_observation_messages(self):
66112
model = LitellmModel(model_name="gpt-4", observation_template="{{ output.output }}")
67113
message = {"extra": {"actions": [{"command": "echo test", "tool_call_id": "call_1"}]}}

0 commit comments

Comments
 (0)