akseljoonas HF Staff Claude Opus 4.6 commited on
Commit
443a99a
·
1 Parent(s): 3ef5441

fix: recover malformed tool calls instead of crashing session

Browse files

When the LLM produces malformed JSON in tool call arguments (common with
large write calls), the session would crash. Now
ContextManager.recover_malformed_tool_calls() sanitizes the JSON, injects
an error result telling the agent to retry with smaller content, and the
agent loop skips execution of those tools.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

agent/context_manager/manager.py CHANGED
@@ -133,11 +133,13 @@ class ContextManager:
133
  def get_messages(self) -> list[Message]:
134
  """Get all messages for sending to LLM.
135
 
136
- Automatically patches any dangling tool_calls (assistant messages
137
- with tool_calls that have no matching tool-result message). This
138
- can happen after errors or cancellations and would cause the LLM
139
- API to reject the request.
 
140
  """
 
141
  self._patch_dangling_tool_calls()
142
  return self.items
143
 
@@ -162,27 +164,80 @@ class ContextManager:
162
  for tc in tool_calls
163
  ]
164
 
165
- def _sanitize_tool_calls(self) -> None:
166
- """Fix malformed tool_call arguments across all assistant messages."""
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  import json
168
 
 
 
 
169
  for msg in self.items:
170
  if getattr(msg, "role", None) != "assistant":
171
  continue
172
  tool_calls = getattr(msg, "tool_calls", None)
173
  if not tool_calls:
174
  continue
175
- # Ensure proper ToolCall objects (litellm streaming can leave dicts)
176
  self._normalize_tool_calls(msg)
177
  for tc in msg.tool_calls:
178
  try:
179
  json.loads(tc.function.arguments)
180
- except (json.JSONDecodeError, TypeError, ValueError):
181
  logger.warning(
182
- "Sanitizing malformed arguments for tool_call %s (%s)",
183
- tc.id, tc.function.name,
184
  )
185
  tc.function.arguments = "{}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  def _patch_dangling_tool_calls(self) -> None:
187
  """Add stub tool results for any tool_calls that lack a matching result.
188
 
 
133
  def get_messages(self) -> list[Message]:
134
  """Get all messages for sending to LLM.
135
 
136
+ Automatically recovers malformed tool_call arguments and patches
137
+ any dangling tool_calls (assistant messages with tool_calls that
138
+ have no matching tool-result message). Both can happen after
139
+ errors or cancellations and would cause the LLM API to reject the
140
+ request.
141
  """
142
+ self.recover_malformed_tool_calls()
143
  self._patch_dangling_tool_calls()
144
  return self.items
145
 
 
164
  for tc in tool_calls
165
  ]
166
 
167
+ def recover_malformed_tool_calls(self) -> set[str]:
168
+ """Sanitize malformed tool_call arguments and inject error results.
169
+
170
+ For every tool_call whose arguments are not valid JSON:
171
+ 1. Replaces the arguments with ``"{}"`` so the context stays
172
+ valid for the LLM API.
173
+ 2. Injects a ``tool`` result message explaining the error and
174
+ asking the agent to retry with smaller content.
175
+
176
+ This method is idempotent — safe to call from both the agent loop
177
+ (before tool execution) and from :meth:`get_messages` (safety net).
178
+
179
+ Returns:
180
+ Set of tool_call IDs that had malformed arguments.
181
+ """
182
  import json
183
 
184
+ malformed_ids: set[str] = set()
185
+
186
+ # 1. Find and sanitize malformed arguments
187
  for msg in self.items:
188
  if getattr(msg, "role", None) != "assistant":
189
  continue
190
  tool_calls = getattr(msg, "tool_calls", None)
191
  if not tool_calls:
192
  continue
 
193
  self._normalize_tool_calls(msg)
194
  for tc in msg.tool_calls:
195
  try:
196
  json.loads(tc.function.arguments)
197
+ except (json.JSONDecodeError, TypeError, ValueError) as e:
198
  logger.warning(
199
+ "Malformed arguments for tool_call %s (%s): %s",
200
+ tc.id, tc.function.name, e,
201
  )
202
  tc.function.arguments = "{}"
203
+ malformed_ids.add(tc.id)
204
+
205
+ if not malformed_ids:
206
+ return malformed_ids
207
+
208
+ # 2. Inject error results for malformed calls that don't have one yet
209
+ answered_ids = {
210
+ getattr(m, "tool_call_id", None)
211
+ for m in self.items
212
+ if getattr(m, "role", None) == "tool"
213
+ }
214
+ for msg in self.items:
215
+ if getattr(msg, "role", None) != "assistant":
216
+ continue
217
+ tool_calls = getattr(msg, "tool_calls", None)
218
+ if not tool_calls:
219
+ continue
220
+ for tc in msg.tool_calls:
221
+ if tc.id in malformed_ids and tc.id not in answered_ids:
222
+ self.items.append(
223
+ Message(
224
+ role="tool",
225
+ content=(
226
+ f"ERROR: Your tool call to '{tc.function.name}' had malformed "
227
+ f"JSON arguments and was NOT executed. This usually happens "
228
+ f"when the content is too large and gets truncated. "
229
+ f"Please retry with smaller content — for 'write', split the "
230
+ f"file into multiple smaller writes using 'edit' to build up "
231
+ f"the file incrementally."
232
+ ),
233
+ tool_call_id=tc.id,
234
+ name=tc.function.name,
235
+ )
236
+ )
237
+ answered_ids.add(tc.id)
238
+
239
+ return malformed_ids
240
+
241
  def _patch_dangling_tool_calls(self) -> None:
242
  """Add stub tool results for any tool_calls that lack a matching result.
243
 
agent/core/agent_loop.py CHANGED
@@ -349,11 +349,32 @@ class Handlers:
349
  if session.is_cancelled:
350
  break
351
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  # Separate tools into those requiring approval and those that don't
353
  approval_required_tools = []
354
  non_approval_tools = []
355
 
356
  for tc in tool_calls:
 
 
357
  tool_name = tc.function.name
358
  try:
359
  tool_args = json.loads(tc.function.arguments)
 
349
  if session.is_cancelled:
350
  break
351
 
352
+ # Recover any malformed tool calls (sanitize JSON + inject
353
+ # error results). Returns IDs to skip during execution.
354
+ malformed_ids = session.context_manager.recover_malformed_tool_calls()
355
+ for mid in malformed_ids:
356
+ await session.send_event(
357
+ Event(
358
+ event_type="tool_output",
359
+ data={
360
+ "tool": next(
361
+ (tc.function.name for tc in tool_calls if tc.id == mid),
362
+ "unknown",
363
+ ),
364
+ "tool_call_id": mid,
365
+ "output": "Malformed tool call — see error in context.",
366
+ "success": False,
367
+ },
368
+ )
369
+ )
370
+
371
  # Separate tools into those requiring approval and those that don't
372
  approval_required_tools = []
373
  non_approval_tools = []
374
 
375
  for tc in tool_calls:
376
+ if tc.id in malformed_ids:
377
+ continue
378
  tool_name = tc.function.name
379
  try:
380
  tool_args = json.loads(tc.function.arguments)