Repository navigation
Description
Background and motivation
[API Proposal]: Add and remove tools mid-conversation without invalidating the prompt cache
Background and motivation
Tool definitions sit at the start of every request, ahead of the conversation, so changing ChatOptions.Tools partway through a conversation changes the prompt prefix and invalidates the prompt cache. With cached input priced at around a tenth (in some cases, even less) of uncached input on current models, a conversation that gains a tool after a long history pays for that whole history again.
Providers now accept a tool change at a position in the conversation, leaving everything before it, the tools array included, unchanged:
| Provider | Positional form | Removal |
|---|---|---|
| Anthropic Messages | tool_addition / tool_removal blocks in a mid-conversation role: "system" message (beta inline-tools-2026-09-15; docs) |
Yes |
| OpenAI Responses | additional_tools input item, role: "developer", full definitions (docs) |
No item |
| OpenAI Chat Completions, Gemini, others | None |
There's no way to express this through IChatClient today. It is a different thing from deferral (HostedToolSearchTool.DeferredTools, #7371): a deferred tool is declared up front and loaded later, while a positional change introduces or withdraws a tool at a point in the history.
Some places where tools appear partway through a conversation: an agent gains a reply_to_user tool once the user messages it mid-run; a skill or plugin is loaded on demand; an MCP server connects or reconnects; a capability unlocks after a login or approval.
API Proposal
namespace Microsoft.Extensions.AI;
[Experimental(DiagnosticIds.Experiments.AIToolChanges)]
public sealed class ToolAdditionContent : AIContent
{
public ToolAdditionContent(AIFunctionDeclaration tool);
// Serialized as name, description and JSON schemas; deserializes to a declaration.
public AIFunctionDeclaration Tool { get; }
}
[Experimental(DiagnosticIds.Experiments.AIToolChanges)]
public sealed class ToolRemovalContent : AIContent
{
public ToolRemovalContent(string toolName);
public string ToolName { get; }
}Semantics:
- The content says when the model was told about a tool.
ChatOptions.Toolsstays the set of tools that can be invoked.FunctionInvokingChatClientdoesn't change: it already resolves calls fromChatOptions.Toolson every iteration. - Adapters with a positional form leave out of the top-level tool definitions any tool in
ChatOptions.Toolswhose first change in the history is aToolAdditionContent. They send the content's definition at its position instead. Every other tool is declared up front as today. - Adapters without one apply the changes to the tool definitions they send: they drop tools whose last change is a removal and add tools whose last change is an addition. They then remove the content from the history. This costs a cache miss but keeps behavior correct.
- The definition sent at the position comes from the content, not from
ChatOptions.Tools. That keeps the earlier part of the conversation identical from request to request, including after the tool is taken out ofChatOptions.Toolsagain.
API Usage
IChatClient client = inner.AsBuilder().UseFunctionInvocation().Build();
AIFunction replyToUser = AIFunctionFactory.Create(ReplyToUser);
// A message arrives mid-run: make the tool invocable, and tell the model about it here.
options.Tools = [.. options.Tools, replyToUser];
history.Add(new(ChatRole.System,
[
new TextContent("The user sent a message: \"Also, say hi!\" Answer it with ReplyToUser."),
new ToolAdditionContent(replyToUser),
]));
ChatResponse response = await client.GetResponseAsync(history, options);The draft PR (#7823) implements this for the OpenAI Responses client (additional_tools) and the Chat Completions client (the apply-to-tools fallback). A matching prototype of the Anthropic adapter, which lives in anthropics/anthropic-sdk-csharp, maps the content to tool_addition / tool_removal. Both were checked against the live APIs using the same scenario: a long prefix, then a tool introduced by a mid-conversation message, compared with adding the same tool to ChatOptions.Tools. The table shows cached input tokens on the first request after the change:
| Model | ToolAdditionContent |
Tool added to ChatOptions.Tools |
|---|---|---|
gpt-5.4-mini (Responses) |
3,072 | 0 |
gpt-5.5 (Responses) |
2,560 | 0 |
claude-sonnet-5-5 (Anthropic, prototype adapter) |
5,325 | 0 |
In every run, FunctionInvokingChatClient invoked the tool the history introduced.
Alternative Designs
FunctionInvokingChatClientinvokes functions found in the history. This makes the conversation carry the ability to run code, and it breaks for persisted histories, because anAIFunctioncan't be deserialized. A second registry would be needed anyway.- A per-request list of tools that can be invoked but aren't declared, such as a
ChatOptionscounterpart toFunctionInvokingChatClient.AdditionalTools. That's a second tool list with subtle semantics. With the proposal,ChatOptions.Toolsalready serves that purpose, and the history decides where each tool is declared. - Content that carries only the tool name, with the definition taken from
ChatOptions.Tools. Once the tool is removed fromChatOptions.Tools, the earlier addition can't be replayed identically, which breaks the cache from that point. - A tool-change list on
ChatOptions, keyed by message index. Positions in a separate list drift when callers trim or compact the history. Content moves with its message.
Risks
- Silent drop. Adapters ignore
AIContentthey don't know, so an adapter that doesn't implement this would hide an added tool from the model. A shared helper that applies the fallback would make that one call per adapter. Should it be public, so adapters outside this repository (Anthropic, Gemini) can use it? The draft PR keeps it internal to the OpenAI library. - Removal on the Responses API. It has no removal item. The draft doesn't send
ToolRemovalContentthere, andFunctionInvokingChatClientreports a call to a tool no longer inChatOptions.Toolsas not found. Narrowing withtool_choice.allowed_toolsis an option, but it interacts withChatOptions.ToolMode. - Function declarations only. Hosted tools (for example
HostedWebSearchTool) can't be serialized, so they're left for later. - Placement rules are the provider's. For example, Anthropic accepts tool changes only directly after a user turn. Adapters map the content where the caller put it and let the API reject an invalid placement, rather than moving it.
- Experimental registration. The types are registered the way other experimental
AIContenttypes were before they were stabilized: inAIJsonUtilities.DefaultOptions, not as[JsonDerivedType]onAIContent.
Generated with Claude Opus 5.5 (Claude Code)
API Proposal
namespace System.Collections.Generic;
public class MyFancyCollection<T> : IEnumerable<T>
{
public void Fancy(T item);
}API Usage
// Fancy the value
var c = new MyFancyCollection<int>();
c.Fancy(42);
// Getting the values out
foreach (var v in c)
Console.WriteLine(v);Alternative Designs
No response
Risks
No response
Activity