Run every tool call a reply asked for, not only the first - #1446
Merged
Merged
Conversation
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>
Contributor
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
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>
Member
|
reviewed |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.