Skip to content

Commit 7063df5

Browse files
committed
Keep only the consecutive-format-error cap; drop the truncation-message handling
Builds on Entrpi's #851 but keeps only the bounce-loop guard in DefaultAgent (max_consecutive_format_errors, default 3, exit_status=FormatErrorsExceeded) and reverts the LitellmModel truncation_error_template / _is_truncated handling back to main. A successful step resets the counter.
1 parent 2ca82d6 commit 7063df5

3 files changed

Lines changed: 8 additions & 96 deletions

File tree

src/minisweagent/agents/default.py

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,8 @@ 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."""
32+
max_consecutive_format_errors: int = 3
33+
"""Exit after this many format errors in a row (0 = no limit)."""
3634
output_path: Path | None = None
3735
"""Save the trajectory to this path."""
3836

@@ -48,7 +46,7 @@ def __init__(self, model: Model, env: Environment, *, config_class: type = Agent
4846
self.logger = logging.getLogger("agent")
4947
self.cost = 0.0
5048
self.n_calls = 0
51-
self._consecutive_format_errors = 0
49+
self.n_consecutive_format_errors = 0
5250
self._start_time = time.time()
5351

5452
def get_template_vars(self, **kwargs) -> dict:
@@ -98,26 +96,24 @@ def run(self, task: str = "", **kwargs) -> dict:
9896
while True:
9997
try:
10098
self.step()
99+
self.n_consecutive_format_errors = 0 # reset on any clean step
101100
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.
101+
self.n_consecutive_format_errors += 1
102+
if 0 < self.config.max_consecutive_format_errors <= self.n_consecutive_format_errors:
107103
self.add_messages(
108104
{
109105
"role": "exit",
110106
"content": "RepeatedFormatError",
111107
"extra": {"exit_status": "RepeatedFormatError", "submission": ""},
112108
}
113109
)
110+
else:
111+
self.add_messages(*e.messages)
114112
except InterruptAgentFlow as e:
115113
self.add_messages(*e.messages)
116114
except Exception as e:
117115
self.handle_uncaught_exception(e)
118116
raise
119-
else:
120-
self._consecutive_format_errors = 0 # a step completed without a format error
121117
finally:
122118
self.save(self.config.output_path)
123119
if self.messages[-1].get("role") == "exit":

src/minisweagent/models/litellm_model.py

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

99
import litellm
10-
from jinja2 import StrictUndefined, Template
1110
from pydantic import BaseModel
1211

1312
from minisweagent.exceptions import FormatError
@@ -38,13 +37,6 @@ class LitellmModelConfig(BaseModel):
3837
"""Cost tracking mode for this model. Can be "default" or "ignore_errors" (ignore errors/missing cost info)"""
3938
format_error_template: str = "{{ error }}"
4039
"""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)."""
4840
observation_template: str = (
4941
"{% if output.exception_info %}<exception>{{output.exception_info}}</exception>\n{% endif %}"
5042
"<returncode>{{output.returncode}}</returncode>\n<output>\n{{output.output}}</output>"
@@ -102,20 +94,6 @@ def query(self, messages: list[dict[str, str]], **kwargs) -> dict:
10294
# model_dump failed (e.g. unserializable object); fall back to repr
10395
# so the spec contract ("response MUST be persisted") holds unconditionally.
10496
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-
)
11997
raise
12098
message = response.choices[0].message.model_dump()
12199
message["extra"] = {
@@ -151,22 +129,6 @@ def _parse_actions(self, response) -> list[dict]:
151129
tool_calls = response.choices[0].message.tool_calls or []
152130
return parse_toolcall_actions(tool_calls, format_error_template=self.config.format_error_template)
153131

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-
170132
def format_message(self, **kwargs) -> dict:
171133
return expand_multimodal_content(kwargs, pattern=self.config.multimodal_regex)
172134

tests/models/test_litellm_model.py

Lines changed: 0 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -62,52 +62,6 @@ 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-
11165
def test_format_observation_messages(self):
11266
model = LitellmModel(model_name="gpt-4", observation_template="{{ output.output }}")
11367
message = {"extra": {"actions": [{"command": "echo test", "tool_call_id": "call_1"}]}}

0 commit comments

Comments
 (0)