akseljoonas commited on
Commit
e9c82b7
Β·
1 Parent(s): da5e0c7

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
@@ -162,34 +162,6 @@ async def _compact_and_notify(session: Session) -> None:
162
  )
163
 
164
 
165
- def _patch_dangling_tool_calls(session: Session) -> None:
166
- """Add stub tool results for any tool_calls that lack a matching result.
167
-
168
- After cancellation the last assistant message may contain tool_calls
169
- whose results were never recorded. LLM APIs require every tool_call
170
- to have a corresponding tool-result message, so we inject placeholders.
171
- """
172
- items = session.context_manager.items
173
- if not items:
174
- return
175
- last = items[-1]
176
- if getattr(last, "role", None) != "assistant" or not getattr(last, "tool_calls", None):
177
- return
178
- answered_ids = {
179
- getattr(m, "tool_call_id", None)
180
- for m in items
181
- if getattr(m, "role", None) == "tool"
182
- }
183
- for tc in last.tool_calls:
184
- if tc.id not in answered_ids:
185
- items.append(
186
- Message(
187
- role="tool",
188
- content="Cancelled by user.",
189
- tool_call_id=tc.id,
190
- name=tc.function.name,
191
- )
192
- )
193
 
194
 
195
  class Handlers:
@@ -374,7 +346,6 @@ class Handlers:
374
 
375
  # ── Cancellation check: before tool execution ──
376
  if session.is_cancelled:
377
- _patch_dangling_tool_calls(session)
378
  break
379
 
380
  # Separate tools into those requiring approval and those that don't
@@ -558,28 +529,10 @@ class Handlers:
558
 
559
  @staticmethod
560
  async def undo(session: Session) -> None:
561
- """Remove the last complete turn (user msg + all assistant/tool msgs that follow).
562
-
563
- Anthropic requires every tool_use to have a matching tool_result,
564
- so we can't just pop 2 items β€” we must pop everything back to
565
- (and including) the last user message to keep the history valid.
566
- """
567
- items = session.context_manager.items
568
- if not items:
569
- await session.send_event(Event(event_type="undo_complete"))
570
- return
571
-
572
- # Pop from the end until we've removed the last user message
573
- removed_user = False
574
- while items:
575
- msg = items.pop()
576
- if getattr(msg, "role", None) == "user":
577
- removed_user = True
578
- break
579
-
580
- if not removed_user:
581
  logger.warning("Undo: no user message found to remove")
582
-
583
  await session.send_event(Event(event_type="undo_complete"))
584
 
585
  @staticmethod
 
162
  )
163
 
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
 
167
  class Handlers:
 
346
 
347
  # ── Cancellation check: before tool execution ──
348
  if session.is_cancelled:
 
349
  break
350
 
351
  # Separate tools into those requiring approval and those that don't
 
529
 
530
  @staticmethod
531
  async def undo(session: Session) -> None:
532
+ """Remove the last complete turn and notify the frontend."""
533
+ removed = session.context_manager.undo_last_turn()
534
+ if not removed:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
535
  logger.warning("Undo: no user message found to remove")
 
536
  await session.send_event(Event(event_type="undo_complete"))
537
 
538
  @staticmethod