Skip to content

Let a tool delegate add messages between tool rounds - #376

Open
gtokman wants to merge 1 commit into
huggingface:mainfrom
gtokman:steering
Open

gtokman wants to merge 1 commit into
huggingface:mainfrom
gtokman:steering

Conversation

@gtokman

@gtokman gtokman commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Resolves #360.

ToolExecutionDelegate.pendingMessages(in:) is asked after each round of tool calls, before the model's next request. Someone can steer a long response while it runs. The policy around it (an inbox, an acknowledgment, what interrupts the final text) stays in the app. The default adds nothing.

Following your notes on the issue:

  • It returns [Prompt], so a message can carry images.
  • Every provider whose tool loop continues after a round asks, through shared code. That's OpenAI (Chat Completions and Responses), Open Responses, Gemini, MLX and llama.cpp, plus Anthropic's and Ollama's streaming loops. Their nonstreaming responses make a single request. SystemLanguageModel doesn't ask, since Foundation Models runs its own tool loop, and the method's documentation says so.
  • Steering doesn't reset the tool-round limit.
  • Each message's transcript entry is built once and sent in the same form. The entry is built by pendingPrompts (Shared/PendingMessages.swift). Each provider sends it with the same conversion it replays a transcript's prompts with, pulled into one function where that was inline, as Replay OpenAI tool calls as plain, stable JSON #325 does for tool calls. A request made now and the replay of it on a later turn match, so a prompt cache still matches.
  • Failure is documented. Once handed over, the messages are the response's. If it fails, they follow the session's transcriptErrorHandlingPolicy like its other entries, and they aren't handed back.
  • Messages still pending when a response ends without another round stay with the app, to send as the next prompt.

Tests

  • OpenAI, streaming and not: the message follows the tool output, is in the transcript as a prompt, and is byte-for-byte the same in flight and when the next turn replays it.
  • OpenAI image: a message can carry an image.
  • OpenAI round limit: steering doesn't reset it.
  • Anthropic and Ollama: their streaming loops.
  • Chat Completions, Responses, Open Responses and Gemini: one parameterized test.
  • Not covered offline: MLX and llama.cpp build with their traits, but their steering paths need a local model to test.

The full suite has the same failures as main here: the 13 tests that need live services.

This touches some of the files #375 does, and the two branches merge cleanly in either order.

🤖 Generated with Claude Code

https://claude.ai/code/session_01B4WBsc1Jp8fGufe89MhQFB

`ToolExecutionDelegate.pendingMessages(in:)` is asked after each round of
tool calls, before the model's next request, and returns `[Prompt]`, so a
message can carry images. Someone can steer a long response while it
runs. The default adds nothing.

Each message's prompt entry is built once, by shared code
(`pendingPrompts`), and every provider whose tool loop continues after a
round sends it with the same conversion it replays a transcript's
prompts with, as huggingface#325 does for tool calls: the request is the same now
and when a later response replays it, so a prompt cache still matches.
That's OpenAI (Chat Completions and Responses), Open Responses, Gemini,
MLX and llama.cpp, and Anthropic's and Ollama's streaming loops; their
nonstreaming responses make one request. `SystemLanguageModel` documents
that it doesn't ask, as Foundation Models runs its own tool loop.

Steering doesn't reset the tool-round limit. Messages the delegate has
handed over are the response's: if it fails, they follow the session's
`transcriptErrorHandlingPolicy` like its other entries, which the
method's documentation says. Messages still waiting when a response ends
without another round stay with the app, which can send them next.

Resolves huggingface#360.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B4WBsc1Jp8fGufe89MhQFB
gtokman added a commit to gtokman/AnyLanguageModel that referenced this pull request Oct 29, 2026
`ToolExecutionDelegate.pendingMessages(in:)` returns `[Prompt]` rather
than `[String]`, as huggingface#376 has it. Each
message's prompt entry is built once, with the prompt's own transcript
segments, and sent with the same conversion as the transcript's
prompts, so a photo sent while a response runs goes with its message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B4WBsc1Jp8fGufe89MhQFB

@mattt mattt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, @gtokman. This follows what we discussed on #360, and returning [Prompt] works out nicely.

Two non-blocking notes:

  • The description says the message is byte-for-byte the same in flight and on replay, but the test compares decoded dictionaries. Either is fine with me; the test and the description should just agree.
  • The round-limit test could also check that the error's transcript entries include the steering prompt.

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.

Add messages to a response while it runs (steering between tool rounds)

2 participants