akseljoonas HF Staff commited on
Commit
a1ce5bc
Β·
1 Parent(s): 9d1a532

fix: patch dangling tool calls on error, move undo to context manager

Browse files
agent/context_manager/manager.py CHANGED
@@ -131,9 +131,57 @@ class ContextManager:
131
  self.items.append(message)
132
 
133
  def get_messages(self) -> list[Message]:
134
- """Get all messages for sending to LLM"""
 
 
 
 
 
 
 
135
  return self.items
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  async def compact(
138
  self, model_name: str, tool_specs: list[dict] | None = None
139
  ) -> None:
 
131
  self.items.append(message)
132
 
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
 
144
+ def _patch_dangling_tool_calls(self) -> None:
145
+ """Add stub tool results for any tool_calls that lack a matching result."""
146
+ if not self.items:
147
+ return
148
+ last = self.items[-1]
149
+ if getattr(last, "role", None) != "assistant" or not getattr(last, "tool_calls", None):
150
+ return
151
+ answered_ids = {
152
+ getattr(m, "tool_call_id", None)
153
+ for m in self.items
154
+ if getattr(m, "role", None) == "tool"
155
+ }
156
+ for tc in last.tool_calls:
157
+ if tc.id not in answered_ids:
158
+ self.items.append(
159
+ Message(
160
+ role="tool",
161
+ content="Tool was not executed (interrupted or error).",
162
+ tool_call_id=tc.id,
163
+ name=tc.function.name,
164
+ )
165
+ )
166
+
167
+ def undo_last_turn(self) -> bool:
168
+ """Remove the last complete turn (user msg + all assistant/tool msgs that follow).
169
+
170
+ Pops from the end until the last user message is removed, keeping the
171
+ tool_use/tool_result pairing valid.
172
+
173
+ Returns True if a user message was found and removed.
174
+ """
175
+ if not self.items:
176
+ return False
177
+
178
+ while self.items:
179
+ msg = self.items.pop()
180
+ if getattr(msg, "role", None) == "user":
181
+ return True
182
+
183
+ return False
184
+
185
  async def compact(
186
  self, model_name: str, tool_specs: list[dict] | None = None
187
  ) -> None:
agent/core/agent_loop.py CHANGED
@@ -163,34 +163,6 @@ async def _compact_and_notify(session: Session) -> None:
163
  )
164
 
165
 
166
- def _patch_dangling_tool_calls(session: Session) -> None:
167
- """Add stub tool results for any tool_calls that lack a matching result.
168
-
169
- After cancellation the last assistant message may contain tool_calls
170
- whose results were never recorded. LLM APIs require every tool_call
171
- to have a corresponding tool-result message, so we inject placeholders.
172
- """
173
- items = session.context_manager.items
174
- if not items:
175
- return
176
- last = items[-1]
177
- if getattr(last, "role", None) != "assistant" or not getattr(last, "tool_calls", None):
178
- return
179
- answered_ids = {
180
- getattr(m, "tool_call_id", None)
181
- for m in items
182
- if getattr(m, "role", None) == "tool"
183
- }
184
- for tc in last.tool_calls:
185
- if tc.id not in answered_ids:
186
- items.append(
187
- Message(
188
- role="tool",
189
- content="Cancelled by user.",
190
- tool_call_id=tc.id,
191
- name=tc.function.name,
192
- )
193
- )
194
 
195
 
196
  class Handlers:
@@ -382,7 +354,6 @@ class Handlers:
382
 
383
  # ── Cancellation check: before tool execution ──
384
  if session.is_cancelled:
385
- _patch_dangling_tool_calls(session)
386
  break
387
 
388
  # Separate tools into those requiring approval and those that don't
@@ -566,28 +537,10 @@ class Handlers:
566
 
567
  @staticmethod
568
  async def undo(session: Session) -> None:
569
- """Remove the last complete turn (user msg + all assistant/tool msgs that follow).
570
-
571
- Anthropic requires every tool_use to have a matching tool_result,
572
- so we can't just pop 2 items β€” we must pop everything back to
573
- (and including) the last user message to keep the history valid.
574
- """
575
- items = session.context_manager.items
576
- if not items:
577
- await session.send_event(Event(event_type="undo_complete"))
578
- return
579
-
580
- # Pop from the end until we've removed the last user message
581
- removed_user = False
582
- while items:
583
- msg = items.pop()
584
- if getattr(msg, "role", None) == "user":
585
- removed_user = True
586
- break
587
-
588
- if not removed_user:
589
  logger.warning("Undo: no user message found to remove")
590
-
591
  await session.send_event(Event(event_type="undo_complete"))
592
 
593
  @staticmethod
 
163
  )
164
 
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
 
168
  class Handlers:
 
354
 
355
  # ── Cancellation check: before tool execution ──
356
  if session.is_cancelled:
 
357
  break
358
 
359
  # Separate tools into those requiring approval and those that don't
 
537
 
538
  @staticmethod
539
  async def undo(session: Session) -> None:
540
+ """Remove the last complete turn and notify the frontend."""
541
+ removed = session.context_manager.undo_last_turn()
542
+ if not removed:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
543
  logger.warning("Undo: no user message found to remove")
 
544
  await session.send_event(Event(event_type="undo_complete"))
545
 
546
  @staticmethod