|
7 | 7 | from typing import Any, Literal |
8 | 8 |
|
9 | 9 | import litellm |
| 10 | +from jinja2 import StrictUndefined, Template |
10 | 11 | from pydantic import BaseModel |
11 | 12 |
|
12 | 13 | from minisweagent.exceptions import FormatError |
@@ -37,6 +38,13 @@ class LitellmModelConfig(BaseModel): |
37 | 38 | """Cost tracking mode for this model. Can be "default" or "ignore_errors" (ignore errors/missing cost info)""" |
38 | 39 | format_error_template: str = "{{ error }}" |
39 | 40 | """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).""" |
40 | 48 | observation_template: str = ( |
41 | 49 | "{% if output.exception_info %}<exception>{{output.exception_info}}</exception>\n{% endif %}" |
42 | 50 | "<returncode>{{output.returncode}}</returncode>\n<output>\n{{output.output}}</output>" |
@@ -94,6 +102,20 @@ def query(self, messages: list[dict[str, str]], **kwargs) -> dict: |
94 | 102 | # model_dump failed (e.g. unserializable object); fall back to repr |
95 | 103 | # so the spec contract ("response MUST be persisted") holds unconditionally. |
96 | 104 | 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 | + ) |
97 | 119 | raise |
98 | 120 | message = response.choices[0].message.model_dump() |
99 | 121 | message["extra"] = { |
@@ -129,6 +151,22 @@ def _parse_actions(self, response) -> list[dict]: |
129 | 151 | tool_calls = response.choices[0].message.tool_calls or [] |
130 | 152 | return parse_toolcall_actions(tool_calls, format_error_template=self.config.format_error_template) |
131 | 153 |
|
| 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 | + |
132 | 170 | def format_message(self, **kwargs) -> dict: |
133 | 171 | return expand_multimodal_content(kwargs, pattern=self.config.multimodal_regex) |
134 | 172 |
|
|
0 commit comments