Skip to content

Fix background tool events lost and mispositioned in history#173

Open
drago-balto wants to merge 3 commits intocartesia-ai:mainfrom
drago-balto:fix/background-event-cleanup
Open

Fix background tool events lost and mispositioned in history#173
drago-balto wants to merge 3 commits intocartesia-ai:mainfrom
drago-balto:fix/background-event-cleanup

Conversation

@drago-balto
Copy link
Contributor

@drago-balto drago-balto commented Mar 10, 2026

Summary

  • Fix background tool events being lost when process() is cancelled mid-wait
  • Fix background tool results appearing in wrong position in history (causing 'conversation cannot end with assistant message' error)

Issue 1: Events lost on cancellation

When _maybe_await_background_event() was cancelled (e.g., user spoke), the queue.get() task was left orphaned and could consume the next event from the queue, causing it to be lost.

Fix: Wrap asyncio.wait() in try/except to properly cancel the get_event_task on external cancellation.

Issue 2: Wrong history position

Background tool events were added to history with the triggering event_id (from when tool was called). When the tool yields much later after user spoke and a new process() started, events were placed in the OLD turns position, causing messages to end with assistant text instead of tool result.

Fix: Use current event_id at yield time (_append_local) instead of captured triggering event_id. This ensures background tool results appear at the END of history.

Test plan

  • Added test test_background_tool_event_not_lost_on_cancellation
  • All 265 existing tests pass
  • Manual testing with an agent featuring long-running background tool
  • [ ]
    Generated with Claude Code

Note

Medium Risk
Touches async cancellation and history ordering for background tools; mistakes here could drop tool results or corrupt message sequencing across turns.

Overview
Background tool loopback is made robust across turn cancellation. _maybe_await_background_event() now cancels/awaits its pending queue get() when externally cancelled so it can’t consume events intended for the next process() call.

Background tool yields are re-ordered to the current turn. _execute_backgroundable_tool() stops pinning tool events to the original triggering event_id and instead appends them with the current event context at yield time, preventing late tool results from being inserted mid-history.

Adds test_background_tool_event_not_lost_on_cancellation to cover the cancellation regression.

Written by Cursor Bugbot for commit da23844. This will update automatically on new commits. Configure here.

drago-balto and others added 3 commits March 9, 2026 21:10
When process() was cancelled (e.g., user started speaking during a
background tool operation), the _maybe_await_background_event() method
would leave an orphaned queue.get() task running. This task could then
consume events meant for the next process() call, causing the agent to
miss tool completion results.

The fix wraps the asyncio.wait() call in try/except to ensure the
get_event_task is always cancelled when the method is interrupted.

Scenario this fixes:
1. User initiates a background tool (e.g., sell_stock)
2. Tool yields "pending", agent responds "please wait"
3. User speaks again (conversation continues during transaction)
4. Current process() is cancelled, but get_event_task remains
5. Tool completes, yields "success" - consumed by orphaned task!
6. New process() waits for events but queue is empty
7. Agent stays silent instead of reporting transaction result

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Background tool events were being added to history with the triggering
event_id (from when the tool was called). When the tool yields much later
after user spoke and a new process() started, these events were placed
in the OLD turn's position in the merged history.

This caused the LLM messages to end with assistant text instead of tool
result, triggering the 'conversation cannot end with assistant message'
validation and skipping the LLM call.

Fix: Use the CURRENT event_id at yield time (via _append_local) instead
of the captured triggering_event_id. This ensures background tool results
appear at the END of history when yielded during a new process() call.

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

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

await get_event_task
except asyncio.CancelledError:
pass
raise
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Completed event result silently discarded in cancellation handler

Medium Severity

In the CancelledError handler of _maybe_await_background_event, if get_event_task has already completed (consumed an event from the queue) before the cancellation takes effect, get_event_task.cancel() is a no-op and await get_event_task returns the event tuple — but the result is never captured or put back into _background_event_queue. The event is consumed from the queue and silently discarded. This race occurs when asyncio.wait()'s internal waiter is cancelled just after get_event_task completes but before asyncio.wait() can return. The completed result needs to be retrieved and re-enqueued via put_nowait so the next process() call can pick it up.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant