Sagents.Middleware.PatchToolCalls (Sagents v0.16.2)

Copy Markdown

Middleware that identifies and resolves "dangling tool calls" in the message history.

A "dangling tool call" occurs when an AI message contains tool calls that lack corresponding tool result messages in the conversation history. This creates an incomplete request-response cycle that can confuse the agent and cause LLM API errors.

Problem Scenarios

Dangling tool calls commonly occur due to:

  1. User Interruption: User sends a new message before tool execution completes
  2. Agent Resets: Agent state is restored with incomplete tool calls
  3. Error Handling: Tool execution fails without generating a tool result
  4. State Corruption: Incomplete state updates or message history corruption
  5. Truncated Response: The response hit the output token limit (message status :length), which ends the run before its tool calls execute

Solution

This middleware runs in the before_model phase and:

  1. Scans message history for assistant messages with tool calls
  2. For each tool call, searches forward for a corresponding tool result message
  3. Creates synthetic tool result messages for any dangling tool calls. A call from a truncated response gets an error result telling the model its output was cut off and to retry with smaller output; any other dangling call is reported as cancelled
  4. Returns updated state with patched message history

Position in Middleware Stack

This middleware should run relatively late in the before_model phase, after middleware that might generate or modify messages but before any middleware that expects complete tool call sequences (like HumanInTheLoop).

Usage

# Add to agent with default middleware
{:ok, agent} = Agent.new(%{
  model: model,
  middleware: [PatchToolCalls]
})

# Or with custom middleware stack
{:ok, agent} = Agent.new(
  %{
    model: model,
    middleware: [
      TodoList,
      FileSystem,
      PatchToolCalls,  # Position before HITL
      HumanInTheLoop,
      MyMiddleware
    ]
  },
  replace_default_middleware: true
)

Example

# Before patching:
messages = [
  %Message{role: :system, content: "You are helpful"},
  %Message{role: :assistant, tool_calls: [
    %ToolCall{call_id: "123", name: "search", arguments: %{q: "test"}}
  ]},
  %Message{role: :user, content: "Never mind"}  # User interrupted!
]

# After patching:
messages = [
  %Message{role: :system, content: "You are helpful"},
  %Message{role: :assistant, tool_calls: [
    %ToolCall{call_id: "123", name: "search", arguments: %{q: "test"}}
  ]},
  %Message{role: :tool, tool_results: [
    %ToolResult{
      tool_call_id: "123",
      name: "search",
      content: "Tool call search with id 123 was cancelled - another message came in before it could be completed."
    }
  ]},
  %Message{role: :user, content: "Never mind"}
]

Summary

Functions

Scan messages for dangling tool calls and create synthetic tool results.

Functions

patch_dangling_tool_calls(messages)

Scan messages for dangling tool calls and create synthetic tool results.

A tool call is "dangling" if there is no corresponding tool result message with a matching tool_call_id in any subsequent message.

Returns the patched message list. If no patches are needed, returns the original list unchanged.