Skip to content

fix(ai): orphaned tool_use when parallel tools mix with confirmation-required commands #1191

Description

@HankYuLinksys

Description

When the LLM returns multiple tool_use blocks in a single response and one of them requires confirmation (write commands), the conversation history ends up in a state the Claude Messages API rejects. Three related defects share the same root cause in _processConversation().

Currently low-impact because setDmz is the only write command, but this will become easy to trigger once more write commands are enabled.

Root cause

lib/ai/orchestrator/router_chat_controller.dart — the tool execution loop:

for (final toolUse in toolUseBlocks) {
  final result = await _executeToolUse(toolUse);
  if (result.requiresConfirmation) {
    requiresConfirmation = true;   // flag only, no tool_result added
  } else {
    _state = _state.withMessage(ChatMessage.toolResult(...));  // others DO get results
  }
}
if (requiresConfirmation) return;   // exits only after the for loop completes

1. Orphaned tool_use (no matching tool_result)

Scenario: LLM returns getWanStatus (read) + setDmz (needs confirmation) in one response.

assistant: [ToolUseBlock(getWanStatus), ToolUseBlock(setDmz)]
user: tool_result(getWanStatus)      <- present
(setDmz has no tool_result)          <- orphaned

The Claude Messages API requires every tool_use id to be answered in the single user turn immediately following the assistant turn.

Additionally, ChatMessage.toolResult emits one separate user message per result (toClaudeFormat forces role: 'user'), so even when the count matches, the grouping itself violates the API contract.

2. _pendingConfirmation has only one slot

It is a single field. If two tool_use blocks both require confirmation, the second overwrites the first, and the first can never receive a result.

3. Re-entrancy

_executeToolUse() calls notifyListeners() while the for loop is still running, and the view immediately shows the confirmation dialog. If the user confirms before the remaining await _executeToolUse(...) calls finish, confirmPendingAction() starts a second concurrent _processConversation().

Why _insertPendingToolResults() does not recover from this

  • It is only called from sendMessage() — i.e. it requires the user to type another message. Pressing Confirm goes through confirmPendingAction(), which never calls it.
  • It returns early when the backward scan encounters an existing tool_result. Defect 1 is exactly that case, so the orphan is never repaired.

Proposed approach

Options to evaluate:

  1. When requiresConfirmation is set, append results for the sibling tool_use blocks so the turn is complete (or restructure so multiple results share one user message).
  2. Simpler and safer: disallow mixing confirmation-required and confirmation-free tools in one response — detect and ask the LLM to re-plan.
  3. Ensure the confirmation dialog is only presented after the for loop has finished, to remove the re-entrancy window.

Acceptance criteria

  • A response mixing a read tool and a confirmation-required tool produces a conversation history accepted by the API (no orphaned tool_use)
  • Two confirmation-required tools in one response no longer silently drop the first
  • Confirming while sibling tools are still executing cannot start a concurrent _processConversation()
  • Unit tests in test/ai/orchestrator/router_chat_controller_test.dart cover all three scenarios

Context

Follow-up to #929 (original Router AI Assistant implementation). Blocks safe rollout of additional write commands.

Metadata

Metadata

Assignees

Labels

2.xLabeled for 2.x versionUIbugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions