Skip to content

fix(llm): keep parallel tool results contiguous for OpenAI-compatible providers - #220

Open
macrocrazier wants to merge 1 commit into
google:mainfrom
macrocrazier:fix/openai-parallel-tool-images
Open

macrocrazier wants to merge 1 commit into
google:mainfrom
macrocrazier:fix/openai-parallel-tool-images

Conversation

@macrocrazier

Copy link
Copy Markdown

Problem

With an OpenAI-compatible model (provider: "openai"), any agent turn that makes two or more tool calls, where at least one returns a screenshot, fails with:

400 An assistant message with 'tool_calls' must be followed by tool messages
responding to each 'tool_call_id'. The following tool_call_ids did not have
response messages: ...

A typical trigger is a Checker turn that calls get_step_screenshot three times plus probe_device once.

Cause

For providers that reject images inside tool messages, tool_result_messages() returns the textual ToolMessage followed by a HumanMessage carrying the image (the "human" image carrier). The agent loops extend history once per tool call, so a parallel batch becomes:

AI(tool_calls=[1,2,3,4]) → Tool1 → Human(img) → Tool2 → Human(img) → Tool3 → Human(img) → Tool4

OpenAI requires every tool result to directly follow the assistant turn. The first interleaved HumanMessage ends that run, so calls 2–4 count as unanswered. Gemini and Anthropic use the "tool" carrier and never hit this.

The same per-call pattern exists in the Checker, Operator, Flash runner, Outputter, Diagnoser, HistoryAnalyzer and committee loops.

Fix

  • order_tool_batch_messages() in artemis/tools/tool_wrapper.py: after each tool-calling AIMessage, emit that turn's ToolMessages first, then the image-carrier HumanMessages in their original order. Valid histories come back unchanged.
  • Applied once in RobustChatModelWrapper._traced_call, which ainvoke, complete and astream all go through. This covers every loop above without touching each call site.

Tests

Added to tests/unit/tools/history/test_result_messages.py:

  • interleaved carriers are moved after all tool results of the turn
  • an already valid history is unchanged
  • the wrapper sends the reordered history to the model

I also confirmed it against a live OpenAI-compatible endpoint: the interleaved order returns 400, and the reordered order returns 200.

make test: no new failures compared with main. main itself currently has 80 failures without GOOGLE_API_KEY set. make lint and make typecheck pass.

… providers

With the "human" image carrier, each screenshot result is a ToolMessage
followed by a HumanMessage holding the image. Agent loops append results
once per tool call, so a parallel batch becomes Tool, Human, Tool, ...
OpenAI-compatible APIs require all tool results to directly follow the
assistant turn and reject that history with a 400 ("tool_call_ids did not
have response messages").

Reorder each tool batch to ToolMessages first, then the image carriers in
their original order, in RobustChatModelWrapper._traced_call so every
agent loop is covered.
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