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:
- 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).
- Simpler and safer: disallow mixing confirmation-required and confirmation-free tools in one response — detect and ask the LLM to re-plan.
- Ensure the confirmation dialog is only presented after the
for loop has finished, to remove the re-entrancy window.
Acceptance criteria
Context
Follow-up to #929 (original Router AI Assistant implementation). Blocks safe rollout of additional write commands.
Description
When the LLM returns multiple
tool_useblocks 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
setDmzis 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:1. Orphaned
tool_use(no matchingtool_result)Scenario: LLM returns
getWanStatus(read) +setDmz(needs confirmation) in one response.The Claude Messages API requires every
tool_useid to be answered in the single user turn immediately following the assistant turn.Additionally,
ChatMessage.toolResultemits one separate user message per result (toClaudeFormatforcesrole: 'user'), so even when the count matches, the grouping itself violates the API contract.2.
_pendingConfirmationhas only one slotIt is a single field. If two
tool_useblocks both require confirmation, the second overwrites the first, and the first can never receive a result.3. Re-entrancy
_executeToolUse()callsnotifyListeners()while theforloop is still running, and the view immediately shows the confirmation dialog. If the user confirms before the remainingawait _executeToolUse(...)calls finish,confirmPendingAction()starts a second concurrent_processConversation().Why
_insertPendingToolResults()does not recover from thissendMessage()— i.e. it requires the user to type another message. Pressing Confirm goes throughconfirmPendingAction(), which never calls it.tool_result. Defect 1 is exactly that case, so the orphan is never repaired.Proposed approach
Options to evaluate:
requiresConfirmationis set, append results for the siblingtool_useblocks so the turn is complete (or restructure so multiple results share one user message).forloop has finished, to remove the re-entrancy window.Acceptance criteria
tool_use)_processConversation()test/ai/orchestrator/router_chat_controller_test.dartcover all three scenariosContext
Follow-up to #929 (original Router AI Assistant implementation). Blocks safe rollout of additional write commands.