Skip to content
Back to skills

469 2026 01 17 Dangling Tool Calls E44184fc

ASecurity

When a user aborted mid-tool-call (Ctrl+C or tool denial), the conversation history was left with a `ModelResponse` containing tool calls but no corresponding `ToolReturn` messages. The next API request failed because the provider expected tool returns for pending calls.

  • 9 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added June 1, 2026
developmentpythonapidocumentation

Works with

  • api

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned June 1, 2026

npx -y skills add tools-only/X-Skills --skill 469-2026-01-17-dangling-tool-calls_e44184fc --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of 469 2026 01 17 Dangling Tool Calls E44184fc?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for 469 2026 01 17 Dangling Tool Calls E44184fc
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/tools-only-469-2026-01-17-dangling-tool-calls-e44184fc/badge)](https://www.skillsdirectory.com/skills/tools-only-469-2026-01-17-dangling-tool-calls-e44184fc)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
title: Dangling Tool Calls on User Abort
link: dangling-tool-calls-abort
type: delta
path: src/tunacode/core/agents/main.py
depth: 0
seams: [M, S]
ontological_relations:
  - relates_to: [[conversation-turns]]
  - affects: [[message-history]]
  - fixes: [[api-error-on-next-request]]
tags:
  - bug
  - abort-handling
  - message-invariants
  - tool-calls
created_at: 2026-01-17T06:00:00Z
updated_at: 2026-01-17T06:00:00Z
uuid: d7c8e9f0-1a2b-3c4d-5e6f-7a8b9c0d1e2f
---

# Dangling Tool Calls on User Abort

## Summary

When a user aborted mid-tool-call (Ctrl+C or tool denial), the conversation history was left with a `ModelResponse` containing tool calls but no corresponding `ToolReturn` messages. The next API request failed because the provider expected tool returns for pending calls.

## Context

- File: `src/tunacode/core/agents/main.py`
- PR: #246
- Surfaces: On any abort during tool execution, next request fails

## Root Cause

**Missing invariant enforcement on exception path.**

The message flow has an implicit invariant:

```
INVARIANT: Every ModelResponse with tool_calls MUST be followed by
           matching ToolReturn(s) before the next ModelRequest
```

The happy path naturally maintains this - tool executes, result recorded. But `UserAbortError` breaks out of the loop BEFORE recording tool returns, violating the invariant.

```
┌─────────────────────────────────────────────────────────────┐
│ Happy Path:                                                 │
│   ModelResponse(tool_calls=[A, B])                          │
│   → execute tools                                           │
│   → ToolReturn(A), ToolReturn(B)                            │
│   → ModelRequest (next turn)  ✓                             │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ Abort Path (BEFORE fix):                                    │
│   ModelResponse(tool_calls=[A, B])                          │
│   → execute tool A                                          │
│   → UserAbortError raised                                   │
│   → messages still has ModelResponse with [A, B]            │
│   → NO ToolReturn recorded                                  │
│   → ModelRequest (next turn)  ✗ API ERROR                   │
└─────────────────────────────────────────────────────────────┘
```

## Changes

Added `_remove_dangling_tool_calls()` in the `except UserAbortError` handler:

```python
except UserAbortError:
    cleanup_applied = _remove_dangling_tool_calls(
        self.state_manager.session.messages,
        self.state_manager.session.tool_call_args_by_id,
    )
    if cleanup_applied:
        self.state_manager.session.update_token_count()
    raise
```

The function walks backward through messages, removing any trailing `ModelResponse` that has unanswered tool calls, and clears cached args.

## Behavioral Impact

- User aborts mid-tool → dangling messages cleaned up
- Next request works normally
- Token count recalculated after cleanup

## Prevention: What We Should Have Done

### 1. Document Message Invariants

The conversation turns doc now exists, but we missed documenting this invariant:

```markdown
## Message Invariants

1. **Tool Call Pairing**: Every `ModelResponse` with tool_calls MUST
   be followed by matching `ToolReturn(s)` before any new `ModelRequest`.

2. **Exception Safety**: Any exception path that exits the agent loop
   must restore message history to a valid state.
```

Add this to `docs/codebase-map/architecture/conversation-turns.md`.

### 2. Add Exception Path Tests

No test existed for abort scenarios:

```python
async def test_abort_mid_tool_call_cleans_up_messages():
    """Verify abort during tool execution doesn't corrupt message history."""
    # Arrange: Start a request that will trigger a tool call
    # Act: Raise UserAbortError during tool execution
    # Assert: messages ends with a valid state (no dangling tool calls)
    # Assert: Next request succeeds
```

### 3. State Machine Should Track Message Validity

The `ResponseState` tracks iteration progress but not message-level invariants. Consider:

```python
def validate_message_invariants(messages: list[Any]) -> bool:
    """Return False if messages violate tool-call pairing invariant."""
    # Walk through messages
    # Track pending tool calls
    # Verify each is answered before next ModelRequest
```

This could be called:
- After each agent iteration (debug mode)
- Before persisting session
- On session load

### 4. Design Pattern: Exception-Safe State Updates

The broader lesson: **state mutations during a loop must be exception-safe**.

Options:
1. **Rollback on exception** (what we did) - detect and fix invalid state
2. **Transactional updates** - only commit state after successful completion
3. **Copy-on-write** - work on a copy, swap on success

For message history, option 2 would be:
```python
# Don't append to session.messages during the loop
# Collect in a local buffer
# Merge to session.messages only on successful completion
```

This is partially what `agent_run.all_messages()` does - but the exception path didn't use it.

## Related Cards

- [[conversation-turns]] - Full turn flow documentation
- [[message-history-persistence]] - How messages are persisted
- [[response-state]] - State machine for agent progress

## Lesson

**Exception paths are first-class citizens.** When designing a stateful loop:

1. List every exception that can exit the loop
2. For each: what state is left behind?
3. For each: is that state valid for the next operation?
4. Add tests for exception scenarios

The bug was obvious in hindsight - we just never traced what happens when `UserAbortError` interrupts the tool execution flow.

Files in this skill

  • README.md1.1 KB
  • skill.md6.2 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.