Skip to content

Run every tool call a reply asked for, not only the first - #1446

Merged
yileicn merged 2 commits into
SciSharp:masterfrom
yuyixg:feature/execute-all-tool-calls
Sep 21, 2026
Merged

yileicn merged 2 commits into
SciSharp:masterfrom
yuyixg:feature/execute-all-tool-calls

Conversation

@yuyixg

@yuyixg yuyixg commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

RoleDialogModel.ToolCalls carries the whole set of calls a model reply asked for, and the OpenAI and Anthropic providers have been filling it. Nothing read it. InvokeAgent dispatched on the singular FunctionName beside it -- the first entry -- and the calls behind it were dropped: no error, no log, and nothing said to the model. A reply asking for three independent lookups ran one, and the model either asked for the other two again on the next turn or answered without them.

The engine now reads the whole set, runs every call in the order the model produced them, appends every result, and asks the model again once for the batch instead of once per call.

The recursion had to move. It lived inside InvokeFunction, at the end of the one call it had just run, which is precisely why nothing after the first call could ever run: the turn was already spent. InvokeFunction now executes one call, appends its result and reports whether the turn should continue; the caller makes that decision once, for the whole batch. Its two existing endings -- StopCompletion, and a rendered response template answering in the function's place -- are unchanged in what they append, they just say so instead of deciding by recursing or not.

Sequential, in the order the model gave. Running the set is the fix; running it concurrently is a separate change with a much narrower safe boundary, since IFunctionCallback implementations share IConversationStateService and the routing context.

A call that ends the turn ends the batch. The calls behind it were asked for without knowing that, and running them into a finished turn produces results nothing reads. They are skipped with a log line rather than silently, which is the failure this commit is about.

Blast radius, deliberately not hidden behind a flag: this changes the behaviour of every existing agent. Prompts that were tuned while only the first call ran -- a prompt that asks for a lookup and a write in one breath and relies on the write being dropped, say -- now get what they asked for. A reply carrying more than one call logs the agent and the tool names at Information, so an agent that starts behaving differently after this can be found from the logs.

The response-template branch still does not persist a Function record while the ordinary branch does (223a83c). That asymmetry is left exactly as it was; each call keeps its own behaviour, and fixing it belongs in its own change.

RoleDialogModel.ToolCalls carries the whole set of calls a model reply asked
for, and the OpenAI and Anthropic providers have been filling it. Nothing read
it. InvokeAgent dispatched on the singular FunctionName beside it -- the first
entry -- and the calls behind it were dropped: no error, no log, and nothing
said to the model. A reply asking for three independent lookups ran one, and the
model either asked for the other two again on the next turn or answered without
them.

The engine now reads the whole set, runs every call in the order the model
produced them, appends every result, and asks the model again once for the batch
instead of once per call.

The recursion had to move. It lived inside InvokeFunction, at the end of the one
call it had just run, which is precisely why nothing after the first call could
ever run: the turn was already spent. InvokeFunction now executes one call,
appends its result and reports whether the turn should continue; the caller
makes that decision once, for the whole batch. Its two existing endings --
StopCompletion, and a rendered response template answering in the function's
place -- are unchanged in what they append, they just say so instead of deciding
by recursing or not.

Sequential, in the order the model gave. Running the set is the fix; running it
concurrently is a separate change with a much narrower safe boundary, since
IFunctionCallback implementations share IConversationStateService and the
routing context.

A call that ends the turn ends the batch. The calls behind it were asked for
without knowing that, and running them into a finished turn produces results
nothing reads. They are skipped with a log line rather than silently, which is
the failure this commit is about.

Blast radius, deliberately not hidden behind a flag: this changes the behaviour
of every existing agent. Prompts that were tuned while only the first call ran
-- a prompt that asks for a lookup and a write in one breath and relies on the
write being dropped, say -- now get what they asked for. A reply carrying more
than one call logs the agent and the tool names at Information, so an agent that
starts behaving differently after this can be found from the logs.

The response-template branch still does not persist a Function record while the
ordinary branch does (223a83c). That asymmetry is left exactly as it was; each
call keeps its own behaviour, and fixing it belongs in its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown
Contributor

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

Reading a reply's tool calls has two implementations. The non-streaming one is
handed a finished list and only has to map it. The streaming one rebuilds the
list from fragments, and it was the single piece of that path with nothing
behind it: no test, and its only logging sits under #if DEBUG, so a Release
deployment using it reports nothing at all.

Reassembly has to guess because StreamingChatToolCallUpdate exposes only
FunctionArgumentsUpdate, FunctionName, Kind and ToolCallId -- no public index.
Which call a fragment belongs to is inferred from the tool call id, present when
a call opens. Ten tests pin that inference: fragments joined per call, two calls
kept apart, two calls of the SAME tool kept apart on id alone, an id repeated on
every fragment not reopening a call, a name arriving after its id, a first
fragment with no id, a call with no arguments, order, and an empty stream.

Checked against the implementation this replaced -- first non-empty name, every
argument fragment concatenated into one string -- four of the ten fail. The other
six pass, which is the shape of the defect: it is invisible while a reply asks
for one tool and produces one malformed argument blob the moment it asks for two.

Same tool, twice, is not hypothetical. Production replies carry up to four copies
of one tool name in a single reply, and the id is the only thing telling those
apart, so grouping must never fall back to the name.

ReconstructToolCalls becomes internal, with InternalsVisibleTo for the test
assembly, rather than being exercised through a live stream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@yileicn

yileicn commented Sep 21, 2026

Copy link
Copy Markdown
Member

reviewed

@yileicn
yileicn merged commit a544ee5 into SciSharp:master Sep 21, 2026
4 checks passed
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.

3 participants