Skip to content

Commit 3d8f743

Browse files
authored
feat(ai): support message-anchored tool loading (#6474)
This adds cache-friendly dynamic tool loading anchored to tool results. Purely additive active-tool changes are recorded with `addedToolNames`, allowing supported Anthropic and OpenAI Responses models to load tool definitions at the point they become available instead of placing them in the cached prompt prefix. It retains safe fallback behavior for unsupported models and non-additive changes but it will wipe caches.
1 parent d7a48d3 commit 3d8f743

19 files changed

Lines changed: 894 additions & 63 deletions

packages/agent/src/agent-loop.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,7 @@ async function finalizeExecutedToolCall(
734734
);
735735
if (afterResult) {
736736
result = {
737+
...result,
737738
content: afterResult.content ?? result.content,
738739
details: afterResult.details ?? result.details,
739740
terminate: afterResult.terminate ?? result.terminate,
@@ -779,6 +780,7 @@ function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResul
779780
// so the null never enters session history or provider payloads.
780781
content: finalized.result.content ?? [],
781782
details: finalized.result.details,
783+
...(finalized.result.addedToolNames?.length ? { addedToolNames: finalized.result.addedToolNames } : {}),
782784
isError: finalized.isError,
783785
timestamp: Date.now(),
784786
};

packages/agent/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,8 @@ export interface AgentToolResult<T> {
352352
content: (TextContent | ImageContent)[];
353353
/** Arbitrary structured details for logs or UI rendering. */
354354
details: T;
355+
/** Names of tools introduced by this result and available from this transcript point onward. */
356+
addedToolNames?: string[];
355357
/**
356358
* Hint that the agent should stop after the current tool batch.
357359
* Early termination only happens when every finalized tool result in the batch sets this to true.

packages/ai/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
### Added
1212

13+
- Added cache-friendly dynamic tool loading. `ToolResultMessage.addedToolNames` marks where tools from `Context.tools` became available; Anthropic and OpenAI Responses use native deferred loading so late tools stay out of the cached prefix, while other providers continue using `Context.tools` normally.
1314
- Added a separate opt-in `max` thinking level, including native `xhigh` and `max` support for GPT-5.6 and Anthropic adaptive-thinking effort metadata matching Anthropic's documentation: `max` on all adaptive Claude models, native `xhigh` on Opus 4.7/4.8, Sonnet 5, and Fable 5 only.
1415
- Added request-wide input-token pricing tiers to model cost metadata and usage cost calculation.
1516

packages/ai/scripts/generate-models.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@ import {
99
CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL,
1010
CLOUDFLARE_WORKERS_AI_BASE_URL,
1111
} from "../src/api/cloudflare.ts";
12-
import type { AnthropicMessagesCompat, Api, KnownProvider, Model, OpenAICompletionsCompat } from "../src/types.ts";
12+
import type {
13+
AnthropicMessagesCompat,
14+
Api,
15+
KnownProvider,
16+
Model,
17+
OpenAICompletionsCompat,
18+
OpenAIResponsesCompat,
19+
} from "../src/types.ts";
1320

1421
const __filename = fileURLToPath(import.meta.url);
1522
const __dirname = dirname(__filename);
@@ -194,6 +201,15 @@ const ANT_LING_RING_THINKING_LEVEL_MAP = {
194201
} as const;
195202

196203
const MODELS_DEV_OPENAI_UNSUPPORTED_MODEL_IDS = new Set(["gpt-5.6"]);
204+
const OPENAI_TOOL_SEARCH_MODEL_IDS = new Set([
205+
"gpt-5.4",
206+
"gpt-5.4-mini",
207+
"gpt-5.4-pro",
208+
"gpt-5.5",
209+
"gpt-5.6-sol",
210+
"gpt-5.6-terra",
211+
"gpt-5.6-luna",
212+
]);
197213
const OPENAI_LONG_CONTEXT_INPUT_THRESHOLD = 272000;
198214
const OPENAI_SHORT_CONTEXT_CAPPED_MODEL_IDS = new Set([
199215
"gpt-5.4",
@@ -482,6 +498,16 @@ function applyOpenAICompletionsCompatMetadata(model: Model<Api>): void {
482498
}
483499
}
484500

501+
function applyOpenAIToolSearchMetadata(model: Model<Api>): void {
502+
const isOpenAIResponses = model.provider === "openai" && model.api === "openai-responses";
503+
const isOpenAICodex = model.provider === "openai-codex" && model.api === "openai-codex-responses";
504+
if (!(isOpenAIResponses || isOpenAICodex) || !OPENAI_TOOL_SEARCH_MODEL_IDS.has(model.id)) return;
505+
model.compat = {
506+
...(model.compat as OpenAIResponsesCompat | undefined),
507+
supportsToolSearch: true,
508+
};
509+
}
510+
485511
function isGemini3ProModel(modelId: string): boolean {
486512
return /gemini-3(?:\.\d+)?-pro/.test(modelId.toLowerCase());
487513
}
@@ -2178,6 +2204,7 @@ async function generateModels() {
21782204
for (const model of allModels) {
21792205
applyThinkingLevelMetadata(model);
21802206
applyOpenAICompletionsCompatMetadata(model);
2207+
applyOpenAIToolSearchMetadata(model);
21812208
}
21822209

21832210
// Group by provider and deduplicate by model ID

packages/ai/src/api/anthropic-messages.ts

Lines changed: 104 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type {
2929
ToolCall,
3030
ToolResultMessage,
3131
} from "../types.ts";
32+
import { splitDeferredTools } from "../utils/deferred-tools.ts";
3233
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
3334
import { headersToRecord } from "../utils/headers.ts";
3435
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.ts";
@@ -177,9 +178,24 @@ function getAnthropicCompat(
177178
supportsCacheControlOnTools: model.compat?.supportsCacheControlOnTools ?? true,
178179
supportsTemperature: model.compat?.supportsTemperature ?? true,
179180
allowEmptySignature: model.compat?.allowEmptySignature ?? false,
181+
supportsToolReferences: model.compat?.supportsToolReferences ?? defaultSupportsToolReferences(model),
180182
};
181183
}
182184

185+
/**
186+
* Default for `supportsToolReferences`: first-party Anthropic models except
187+
* Haiku (rejects client-side tool_reference blocks) and models that predate
188+
* tool search (Claude 3.x, Opus/Sonnet 4.0, Opus 4.1).
189+
*/
190+
function defaultSupportsToolReferences(model: Model<"anthropic-messages">): boolean {
191+
if (model.provider !== "anthropic" || model.id.includes("haiku")) return false;
192+
const version = model.id.match(/^claude-(?:opus|sonnet|fable)-(\d+)(?:-(\d+))?(?:-|$)/);
193+
if (!version) return false;
194+
const major = Number(version[1]);
195+
const minor = version[2] && version[2].length < 8 ? Number(version[2]) : 0;
196+
return major > 4 || (major === 4 && minor >= 5);
197+
}
198+
183199
export interface AnthropicOptions extends StreamOptions {
184200
/**
185201
* Enable extended thinking.
@@ -907,9 +923,30 @@ function buildParams(
907923
): MessageCreateParamsStreaming {
908924
const { cacheControl } = getCacheControl(model, options?.cacheRetention, options?.env);
909925
const compat = getAnthropicCompat(model);
926+
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
927+
const normalizeToolName = isOAuthToken ? toClaudeCodeName : (name: string) => name;
928+
const toolPlacement = splitDeferredTools(
929+
{ ...context, messages: transformedMessages },
930+
compat.supportsToolReferences,
931+
normalizeToolName,
932+
);
933+
let immediateTools = toolPlacement.immediate;
934+
let deferredTools = [...toolPlacement.deferred.values()];
935+
if (immediateTools.length === 0 && deferredTools.length > 0) {
936+
immediateTools = deferredTools;
937+
deferredTools = [];
938+
}
939+
const deferredToolNames = new Set(deferredTools.map((tool) => normalizeToolName(tool.name)));
910940
const params: MessageCreateParamsStreaming = {
911941
model: model.id,
912-
messages: convertMessages(context.messages, model, isOAuthToken, cacheControl, compat.allowEmptySignature),
942+
messages: convertMessages(
943+
transformedMessages,
944+
isOAuthToken,
945+
cacheControl,
946+
compat.allowEmptySignature,
947+
deferredToolNames,
948+
normalizeToolName,
949+
),
913950
max_tokens: options?.maxTokens ?? model.maxTokens,
914951
stream: true,
915952
};
@@ -946,13 +983,16 @@ function buildParams(
946983
params.temperature = options.temperature;
947984
}
948985

949-
if (context.tools && context.tools.length > 0) {
950-
params.tools = convertTools(
951-
context.tools,
952-
isOAuthToken,
953-
compat.supportsEagerToolInputStreaming,
954-
compat.supportsCacheControlOnTools ? cacheControl : undefined,
955-
);
986+
if (immediateTools.length > 0 || deferredTools.length > 0) {
987+
params.tools = [
988+
...convertTools(
989+
immediateTools,
990+
isOAuthToken,
991+
compat.supportsEagerToolInputStreaming,
992+
compat.supportsCacheControlOnTools ? cacheControl : undefined,
993+
),
994+
...convertTools(deferredTools, isOAuthToken, compat.supportsEagerToolInputStreaming, undefined, true),
995+
];
956996
}
957997

958998
// Configure thinking mode: adaptive, budget-based, or explicitly disabled.
@@ -1009,17 +1049,51 @@ function normalizeToolCallId(id: string): string {
10091049
return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
10101050
}
10111051

1052+
function convertToolResult(
1053+
msg: ToolResultMessage,
1054+
isOAuthToken: boolean,
1055+
deferredToolNames: ReadonlySet<string>,
1056+
loadedToolNames: Set<string>,
1057+
normalizeToolName: (name: string) => string,
1058+
): { toolResult: ContentBlockParam; siblingContent: ContentBlockParam[] } {
1059+
const references: Array<{ type: "tool_reference"; tool_name: string }> = [];
1060+
for (const name of msg.addedToolNames ?? []) {
1061+
const normalizedName = normalizeToolName(name);
1062+
if (!deferredToolNames.has(normalizedName) || loadedToolNames.has(normalizedName)) continue;
1063+
loadedToolNames.add(normalizedName);
1064+
references.push({
1065+
type: "tool_reference",
1066+
tool_name: isOAuthToken ? toClaudeCodeName(name) : name,
1067+
});
1068+
}
1069+
const convertedContent = convertContentBlocks(msg.content);
1070+
// Anthropic rejects tool references mixed with ordinary tool-result content.
1071+
return {
1072+
toolResult: {
1073+
type: "tool_result",
1074+
tool_use_id: msg.toolCallId,
1075+
content: references.length > 0 ? references : convertedContent,
1076+
is_error: msg.isError,
1077+
},
1078+
siblingContent:
1079+
references.length === 0
1080+
? []
1081+
: typeof convertedContent === "string"
1082+
? [{ type: "text", text: convertedContent }]
1083+
: convertedContent,
1084+
};
1085+
}
1086+
10121087
function convertMessages(
1013-
messages: Message[],
1014-
model: Model<"anthropic-messages">,
1088+
transformedMessages: Message[],
10151089
isOAuthToken: boolean,
10161090
cacheControl?: CacheControlEphemeral,
10171091
allowEmptySignature = false,
1092+
deferredToolNames: ReadonlySet<string> = new Set(),
1093+
normalizeToolName: (name: string) => string = (name) => name,
10181094
): MessageParam[] {
10191095
const params: MessageParam[] = [];
1020-
1021-
// Transform messages for cross-provider compatibility
1022-
const transformedMessages = transformMessages(messages, model, normalizeToolCallId);
1096+
const loadedToolNames = new Set<string>();
10231097

10241098
for (let i = 0; i < transformedMessages.length; i++) {
10251099
const msg = transformedMessages[i];
@@ -1122,37 +1196,30 @@ function convertMessages(
11221196
content: blocks,
11231197
});
11241198
} else if (msg.role === "toolResult") {
1125-
// Collect all consecutive toolResult messages, needed for z.ai Anthropic endpoint
1199+
// Collect all consecutive toolResult messages, needed for z.ai Anthropic endpoint.
11261200
const toolResults: ContentBlockParam[] = [];
1127-
1128-
// Add the current tool result
1129-
toolResults.push({
1130-
type: "tool_result",
1131-
tool_use_id: msg.toolCallId,
1132-
content: convertContentBlocks(msg.content),
1133-
is_error: msg.isError,
1134-
});
1135-
1136-
// Look ahead for consecutive toolResult messages
1137-
let j = i + 1;
1201+
const siblingContent: ContentBlockParam[] = [];
1202+
let j = i;
11381203
while (j < transformedMessages.length && transformedMessages[j].role === "toolResult") {
1139-
const nextMsg = transformedMessages[j] as ToolResultMessage; // We know it's a toolResult
1140-
toolResults.push({
1141-
type: "tool_result",
1142-
tool_use_id: nextMsg.toolCallId,
1143-
content: convertContentBlocks(nextMsg.content),
1144-
is_error: nextMsg.isError,
1145-
});
1204+
const converted = convertToolResult(
1205+
transformedMessages[j] as ToolResultMessage,
1206+
isOAuthToken,
1207+
deferredToolNames,
1208+
loadedToolNames,
1209+
normalizeToolName,
1210+
);
1211+
toolResults.push(converted.toolResult);
1212+
siblingContent.push(...converted.siblingContent);
11461213
j++;
11471214
}
11481215

1149-
// Skip the messages we've already processed
1216+
// Skip the messages we've already processed.
11501217
i = j - 1;
11511218

1152-
// Add a single user message with all tool results
1219+
// Displaced reference-bearing results must follow every tool_result block.
11531220
params.push({
11541221
role: "user",
1155-
content: toolResults,
1222+
content: [...toolResults, ...siblingContent],
11561223
});
11571224
}
11581225
}
@@ -1193,6 +1260,7 @@ function convertTools(
11931260
isOAuthToken: boolean,
11941261
supportsEagerToolInputStreaming: boolean,
11951262
cacheControl?: CacheControlEphemeral,
1263+
deferLoading = false,
11961264
): Anthropic.Messages.Tool[] {
11971265
if (!tools) return [];
11981266

@@ -1208,6 +1276,7 @@ function convertTools(
12081276
properties: schema.properties ?? {},
12091277
required: schema.required ?? [],
12101278
},
1279+
...(deferLoading ? { defer_loading: true } : {}),
12111280
...(cacheControl && index === tools.length - 1 ? { cache_control: cacheControl } : {}),
12121281
};
12131282
});

packages/ai/src/api/openai-codex-responses.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import type {
3636
Usage,
3737
} from "../types.ts";
3838
import { combineAbortSignals } from "../utils/abort-signals.ts";
39+
import { splitDeferredTools } from "../utils/deferred-tools.ts";
3940
import {
4041
appendAssistantMessageDiagnostic,
4142
createAssistantMessageDiagnostic,
@@ -481,8 +482,10 @@ function buildRequestBody(
481482
context: Context,
482483
options?: OpenAICodexResponsesOptions,
483484
): RequestBody {
485+
const toolPlacement = splitDeferredTools(context, model.compat?.supportsToolSearch ?? false);
484486
const messages = convertResponsesMessages(model, context, CODEX_TOOL_CALL_PROVIDERS, {
485487
includeSystemPrompt: false,
488+
deferredTools: toolPlacement.deferred,
486489
});
487490

488491
const body: RequestBody = {
@@ -506,8 +509,8 @@ function buildRequestBody(
506509
body.service_tier = options.serviceTier;
507510
}
508511

509-
if (context.tools && context.tools.length > 0) {
510-
body.tools = convertResponsesTools(context.tools, { strict: null });
512+
if (toolPlacement.immediate.length > 0) {
513+
body.tools = convertResponsesTools(toolPlacement.immediate, { strict: null });
511514
}
512515

513516
if (options?.reasoningEffort !== undefined) {

0 commit comments

Comments
 (0)