Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
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 +49 -1
- agent/core/agent_loop.py +3 -50
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
|
| 570 |
-
|
| 571 |
-
|
| 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
|