diff --git a/src/agent/ag-ui/runtime-chat-stream-encoder.test.ts b/src/agent/ag-ui/runtime-chat-stream-encoder.test.ts index b7ca29cb8c..6466c5b776 100644 --- a/src/agent/ag-ui/runtime-chat-stream-encoder.test.ts +++ b/src/agent/ag-ui/runtime-chat-stream-encoder.test.ts @@ -38,6 +38,64 @@ describe("agent/ag-ui-runtime-chat-stream-encoder", () => { ); }); + it("preserves providerExecuted on runtime tool lifecycle events", () => { + const encoder = createAgUiRuntimeChatStreamEncoder({ + responseMessageId: "msg-1", + }); + + assertEquals( + encoder.encode({ + type: "tool-input-start", + toolCallId: "tool-provider-fetch", + toolName: "web_fetch", + providerExecuted: true, + }), + [ + { type: "start-step" }, + { + type: "tool-input-start", + toolCallId: "tool-provider-fetch", + toolName: "web_fetch", + providerExecuted: true, + }, + ], + ); + assertEquals( + encoder.encode({ + type: "tool-input-available", + toolCallId: "tool-provider-fetch", + toolName: "web_fetch", + input: { url: "https://example.com/docs" }, + providerExecuted: true, + }), + [ + { + type: "tool-input-available", + toolCallId: "tool-provider-fetch", + toolName: "web_fetch", + input: { url: "https://example.com/docs" }, + providerExecuted: true, + }, + ], + ); + assertEquals( + encoder.encode({ + type: "tool-output-error", + toolCallId: "tool-provider-fetch", + errorText: "provider failed", + providerExecuted: true, + }), + [ + { + type: "tool-output-error", + toolCallId: "tool-provider-fetch", + errorText: "provider failed", + providerExecuted: true, + }, + ], + ); + }); + it("emits text events with the response message id and block content id", () => { const encoder = createAgUiRuntimeChatStreamEncoder({ responseMessageId: "msg-1", diff --git a/src/agent/ag-ui/runtime-chat-stream-encoder.ts b/src/agent/ag-ui/runtime-chat-stream-encoder.ts index 52a4b82cfa..4a00e7b9eb 100644 --- a/src/agent/ag-ui/runtime-chat-stream-encoder.ts +++ b/src/agent/ag-ui/runtime-chat-stream-encoder.ts @@ -61,6 +61,7 @@ type ToolPart = { toolName: string; inputText: string; input: Record; + providerExecuted?: boolean; }; type PendingToolDelta = { @@ -98,6 +99,11 @@ function getStringField(event: AgUiRuntimeStreamEvent, key: string): string | un return typeof value === "string" ? value : undefined; } +function getBooleanField(event: AgUiRuntimeStreamEvent, key: string): boolean | undefined { + const value = event[key]; + return typeof value === "boolean" ? value : undefined; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -380,17 +386,26 @@ export function createAgUiRuntimeChatStreamEncoder( if (!toolCallId || !toolName) { return events; } + const providerExecuted = getBooleanField(event, "providerExecuted"); const toolPart = toolParts.get(toolCallId); if (!toolPart) { toolParts.set(toolCallId, { toolName, inputText: "", input: {}, + ...(providerExecuted !== undefined ? { providerExecuted } : {}), }); + } else if (providerExecuted !== undefined) { + toolPart.providerExecuted = providerExecuted; } if (!emittedToolInputStartIds.has(toolCallId)) { emittedToolInputStartIds.add(toolCallId); - events.push({ type: "tool-input-start", toolCallId, toolName }); + events.push({ + type: "tool-input-start", + toolCallId, + toolName, + ...(providerExecuted !== undefined ? { providerExecuted } : {}), + }); } const pendingEvents = flushPendingToolDeltas(toolCallId); const existingToolPart = toolParts.get(toolCallId); @@ -453,22 +468,32 @@ export function createAgUiRuntimeChatStreamEncoder( ? parsedPendingInput : inputRecord : inputRecord; + const providerExecuted = getBooleanField(event, "providerExecuted"); if (existingToolPart) { existingToolPart.toolName = toolName; existingToolPart.inputText = pendingInputText; existingToolPart.input = resolvedInputRecord; + if (providerExecuted !== undefined) { + existingToolPart.providerExecuted = providerExecuted; + } } else { toolParts.set(toolCallId, { toolName, inputText: pendingInputText, input: resolvedInputRecord, + ...(providerExecuted !== undefined ? { providerExecuted } : {}), }); } if (!emittedToolInputStartIds.has(toolCallId)) { emittedToolInputStartIds.add(toolCallId); - events.push({ type: "tool-input-start", toolCallId, toolName }); + events.push({ + type: "tool-input-start", + toolCallId, + toolName, + ...(providerExecuted !== undefined ? { providerExecuted } : {}), + }); } events.push(...flushPendingToolDeltas(toolCallId)); events.push({ @@ -476,6 +501,7 @@ export function createAgUiRuntimeChatStreamEncoder( toolCallId, toolName, input: resolvedInputRecord, + ...(providerExecuted !== undefined ? { providerExecuted } : {}), }); return events; } @@ -485,7 +511,13 @@ export function createAgUiRuntimeChatStreamEncoder( if (!toolCallId) { return events; } - events.push({ type: "tool-output-available", toolCallId, output: event.output }); + const providerExecuted = getBooleanField(event, "providerExecuted"); + events.push({ + type: "tool-output-available", + toolCallId, + output: event.output, + ...(providerExecuted !== undefined ? { providerExecuted } : {}), + }); return events; } case "tool-output-error": { @@ -495,7 +527,13 @@ export function createAgUiRuntimeChatStreamEncoder( return events; } const errorText = getStringField(event, "errorText") ?? "Tool execution failed"; - events.push({ type: "tool-output-error", toolCallId, errorText }); + const providerExecuted = getBooleanField(event, "providerExecuted"); + events.push({ + type: "tool-output-error", + toolCallId, + errorText, + ...(providerExecuted !== undefined ? { providerExecuted } : {}), + }); return events; } case "data": { diff --git a/src/agent/hosted/chat-execution-runtime.test.ts b/src/agent/hosted/chat-execution-runtime.test.ts index 049597bdd0..06958ef29a 100644 --- a/src/agent/hosted/chat-execution-runtime.test.ts +++ b/src/agent/hosted/chat-execution-runtime.test.ts @@ -887,7 +887,7 @@ describe("agent/hosted-chat-execution-runtime", () => { assertEquals(terminalStates, [{ status: "completed" }]); }); - it("completes response finalization with provider-native web tool input still open", async () => { + it("completes response finalization with provider-owned web tool input still open", async () => { let streamOptions: HostedChatRuntimeToUiMessageStreamOptions | undefined; const terminalStates: HostedLifecycleTerminalState[] = []; const runtime = createHostedChatExecutionRuntime({ @@ -927,6 +927,7 @@ describe("agent/hosted-chat-execution-runtime", () => { toolCallId: "srvtoolu-fetch", input: { url: "https://veryfront.com/docs/agent/create-agent" }, state: "input-available", + providerExecuted: true, }, ], }), diff --git a/src/agent/hosted/chat-runtime-tool-assembly.test.ts b/src/agent/hosted/chat-runtime-tool-assembly.test.ts index c466e7d37f..5c15f55eff 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.test.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.test.ts @@ -553,6 +553,167 @@ Deno.test("prepareHostedChatRuntimeToolAssembly keeps source provider tools insi assertStringIncludes(toolAssembly.systemInstructions, "- sleep"); }); +Deno.test("prepareHostedChatRuntimeToolAssembly falls back to local web_fetch when OpenAI lacks provider-native web_fetch", async () => { + const taskContext: HostedChatRuntimeToolAssemblyContext = { + authToken: "token", + projectId: "project-1", + model: "openai/gpt-5.4-nano", + }; + + const toolAssembly = await prepareHostedChatRuntimeToolAssembly({ + sourceIntegrationPolicy: unrestrictedSourceIntegrationPolicy, + taskContext, + instructions: "Base instructions", + localTools: { + sleep: localTool("Sleep"), + web_fetch: localTool("Fetch a URL"), + write_file: localTool("Write a file"), + }, + apiUrl: "https://api.example.com", + apiMcpUrl: "https://api.example.com/mcp", + allowedToolNames: ["sleep", "write_file"], + allowedProviderToolNames: ["web_fetch"], + createRemoteToolSource: remoteSourceFromConfig, + preloadLatestConversationUserText: false, + }); + + assertEquals(toolAssembly.localToolNames, ["sleep", "web_fetch", "write_file"]); + assertEquals(toolAssembly.providerToolNames, []); + assertEquals(taskContext.availableToolNames, ["sleep", "web_fetch", "write_file"]); + assertExists(toolAssembly.runtimeTools.web_fetch); + assertStringIncludes(toolAssembly.systemInstructions, "- web_fetch"); +}); + +Deno.test("prepareHostedChatRuntimeToolAssembly does not re-read selected local web_fetch as OpenAI fallback", async () => { + const taskContext: HostedChatRuntimeToolAssemblyContext = { + authToken: "token", + projectId: "project-1", + model: "openai/gpt-5.4-nano", + }; + const webFetchTool = localTool("Fetch a URL"); + const localTools = { + sleep: localTool("Sleep"), + } as Record>; + let webFetchAccessCount = 0; + Object.defineProperty(localTools, "web_fetch", { + enumerable: true, + configurable: true, + get() { + webFetchAccessCount += 1; + return webFetchTool; + }, + }); + + const toolAssembly = await prepareHostedChatRuntimeToolAssembly({ + sourceIntegrationPolicy: unrestrictedSourceIntegrationPolicy, + taskContext, + instructions: "Base instructions", + localTools, + apiUrl: "https://api.example.com", + apiMcpUrl: "https://api.example.com/mcp", + allowedToolNames: ["sleep", "web_fetch"], + allowedProviderToolNames: ["web_fetch"], + createRemoteToolSource: remoteSourceFromConfig, + preloadLatestConversationUserText: false, + }); + + assertEquals(webFetchAccessCount, 1); + assertEquals(toolAssembly.localToolNames, ["sleep", "web_fetch"]); + assertEquals(toolAssembly.providerToolNames, []); + assertEquals(taskContext.availableToolNames, ["sleep", "web_fetch"]); + assertExists(toolAssembly.runtimeTools.web_fetch); +}); + +Deno.test("prepareHostedChatRuntimeToolAssembly denies OpenAI local web_fetch fallback when direct allowed tools exclude it", async () => { + const taskContext: HostedChatRuntimeToolAssemblyContext = { + authToken: "token", + projectId: "project-1", + model: "openai/gpt-5.4-nano", + }; + + const toolAssembly = await prepareHostedChatRuntimeToolAssembly({ + sourceIntegrationPolicy: unrestrictedSourceIntegrationPolicy, + taskContext, + instructions: "Base instructions", + localTools: { + sleep: localTool("Sleep"), + web_fetch: localTool("Fetch a URL"), + }, + apiUrl: "https://api.example.com", + apiMcpUrl: "https://api.example.com/mcp", + allowedToolNames: ["sleep"], + sourceProviderToolNames: ["web_fetch"], + createRemoteToolSource: remoteSourceFromConfig, + preloadLatestConversationUserText: false, + }); + + assertEquals(toolAssembly.localToolNames, ["sleep"]); + assertEquals(toolAssembly.providerToolNames, []); + assertEquals(taskContext.availableToolNames, ["sleep"]); + assertEquals(toolAssembly.runtimeTools.web_fetch, undefined); +}); + +Deno.test("prepareHostedChatRuntimeToolAssembly keeps empty provider allowlist as local web_fetch fallback denial", async () => { + const taskContext: HostedChatRuntimeToolAssemblyContext = { + authToken: "token", + projectId: "project-1", + model: "openai/gpt-5.4-nano", + }; + + const toolAssembly = await prepareHostedChatRuntimeToolAssembly({ + sourceIntegrationPolicy: unrestrictedSourceIntegrationPolicy, + taskContext, + instructions: "Base instructions", + localTools: { + sleep: localTool("Sleep"), + web_fetch: localTool("Fetch a URL"), + }, + apiUrl: "https://api.example.com", + apiMcpUrl: "https://api.example.com/mcp", + allowedToolNames: ["sleep"], + allowedProviderToolNames: [], + sourceProviderToolNames: ["web_fetch"], + createRemoteToolSource: remoteSourceFromConfig, + preloadLatestConversationUserText: false, + }); + + assertEquals(toolAssembly.localToolNames, ["sleep"]); + assertEquals(toolAssembly.providerToolNames, []); + assertEquals(taskContext.availableToolNames, ["sleep"]); + assertEquals(toolAssembly.runtimeTools.web_fetch, undefined); +}); + +Deno.test("prepareHostedChatRuntimeToolAssembly does not duplicate Anthropic provider-native web_fetch with the local fallback", async () => { + const taskContext: HostedChatRuntimeToolAssemblyContext = { + authToken: "token", + projectId: "project-1", + model: "anthropic/claude-sonnet-4-6", + }; + + const toolAssembly = await prepareHostedChatRuntimeToolAssembly({ + sourceIntegrationPolicy: unrestrictedSourceIntegrationPolicy, + taskContext, + instructions: "Base instructions", + localTools: { + sleep: localTool("Sleep"), + web_fetch: localTool("Fetch a URL"), + }, + apiUrl: "https://api.example.com", + apiMcpUrl: "https://api.example.com/mcp", + allowedToolNames: ["sleep"], + allowedProviderToolNames: ["web_fetch"], + sourceProviderToolNames: ["web_fetch"], + createRemoteToolSource: remoteSourceFromConfig, + preloadLatestConversationUserText: false, + }); + + assertEquals(toolAssembly.localToolNames, ["sleep"]); + assertEquals(toolAssembly.providerToolNames, ["web_fetch"]); + assertEquals(taskContext.availableToolNames, ["sleep", "web_fetch"]); + assertEquals(toolAssembly.runtimeTools.web_fetch, undefined); + assertStringIncludes(toolAssembly.systemInstructions, "- web_fetch"); +}); + Deno.test("prepareHostedChatRuntimeToolAssembly preloads default research artifacts", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = () => diff --git a/src/agent/hosted/chat-runtime-tool-assembly.ts b/src/agent/hosted/chat-runtime-tool-assembly.ts index f9e8f905aa..7af97cba2c 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.ts @@ -197,6 +197,28 @@ export function filterHostedChatRuntimeLocalTools(input: { return Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right))); } +function shouldIncludeHostedWebFetchFallback(input: { + localTools: HostToolSet; + sourceProviderToolNames: Set; + allowedToolNames: ReadonlySet | null; + allowedProviderToolNames: ReadonlySet | null; + providerNativeToolNames: readonly string[]; +}): boolean { + if (!Object.hasOwn(input.localTools, "web_fetch")) { + return false; + } + if (input.providerNativeToolNames.includes("web_fetch")) { + return false; + } + if (input.allowedProviderToolNames !== null) { + return input.allowedProviderToolNames.has("web_fetch"); + } + if (input.allowedToolNames !== null) { + return input.allowedToolNames.has("web_fetch"); + } + return input.sourceProviderToolNames.has("web_fetch"); +} + /** Prepare hosted chat runtime tool assembly. */ export async function prepareHostedChatRuntimeToolAssembly< TTraceAttributes extends HostToolTraceAttributes = HostToolTraceAttributes, @@ -214,15 +236,40 @@ export async function prepareHostedChatRuntimeToolAssembly< availableSkillIds: input.taskContext.availableSkillIds, includeRuntimeEssentialToolsWhenEmpty: input.includeRuntimeEssentialToolsWhenEmpty, }); + const postFormInputLocalTools = filterPostFormInputLocalTools( + input.localTools, + input.taskContext, + ); const selectedLocalTools = filterHostedChatRuntimeLocalTools({ - tools: filterPostFormInputLocalTools(input.localTools, input.taskContext), + tools: postFormInputLocalTools, allowedToolNames, sourceProviderToolNames: input.sourceProviderToolNames, }); + const sourceProviderToolNames = new Set(input.sourceProviderToolNames ?? []); + const allowedProviderToolNames = normalizeHostedRuntimeAllowedToolNames( + input.allowedProviderToolNames, + ); + const providerNativeToolNames = getProviderNativeToolNames({ model: input.taskContext.model }); + const sortedLocalToolEntries = Object.entries(selectedLocalTools).filter(([toolName]) => + isIntegrationToolAllowedBySourcePolicy(toolName, input.sourceIntegrationPolicy) + ); + if ( + !Object.hasOwn(selectedLocalTools, "web_fetch") && + shouldIncludeHostedWebFetchFallback({ + localTools: postFormInputLocalTools, + sourceProviderToolNames, + allowedToolNames, + allowedProviderToolNames, + providerNativeToolNames, + }) && isIntegrationToolAllowedBySourcePolicy("web_fetch", input.sourceIntegrationPolicy) + ) { + const hostedWebFetchTool = postFormInputLocalTools.web_fetch; + if (hostedWebFetchTool !== undefined) { + sortedLocalToolEntries.push(["web_fetch", hostedWebFetchTool]); + } + } const sortedLocalTools = Object.fromEntries( - Object.entries(selectedLocalTools).filter(([toolName]) => - isIntegrationToolAllowedBySourcePolicy(toolName, input.sourceIntegrationPolicy) - ), + sortedLocalToolEntries.sort(([left], [right]) => left.localeCompare(right)), ); const localHostTools = input.traceLocalTools ? traceHostTools(sortedLocalTools, input.traceLocalTools) @@ -257,11 +304,6 @@ export async function prepareHostedChatRuntimeToolAssembly< listedRemoteToolNames, input.sourceIntegrationPolicy, ); - const sourceProviderToolNames = new Set(input.sourceProviderToolNames ?? []); - const allowedProviderToolNames = normalizeHostedRuntimeAllowedToolNames( - input.allowedProviderToolNames, - ); - const providerNativeToolNames = getProviderNativeToolNames({ model: input.taskContext.model }); const localProviderToolNames = new Set( Object.keys(sortedLocalTools).filter((toolName) => providerNativeToolNames.includes(toolName)), ); diff --git a/src/agent/hosted/finalized-message.test.ts b/src/agent/hosted/finalized-message.test.ts index a71c92650a..c4ac8ed156 100644 --- a/src/agent/hosted/finalized-message.test.ts +++ b/src/agent/hosted/finalized-message.test.ts @@ -59,7 +59,7 @@ Deno.test("buildFinalizedMessageState does not fail provider-owned input-availab ]); }); -Deno.test("buildFinalizedMessageState does not fail provider-native web tools when providerExecuted is omitted", () => { +Deno.test("buildFinalizedMessageState fails local web_fetch input-available tools without providerExecuted", () => { const result = buildFinalizedMessageState({ responseMessage: { id: "assistant-1", @@ -79,14 +79,15 @@ Deno.test("buildFinalizedMessageState does not fail provider-native web tools wh incompleteToolCallsPartErrorText: "tool error", }); - assertEquals(result.hasIncompleteFinalizedToolParts, false); + assertEquals(result.hasIncompleteFinalizedToolParts, true); assertEquals(result.sanitizedFinalizedMessage.parts, [ { type: "text", text: "Done" }, { type: "tool-web_fetch", toolCallId: "srvtoolu-fetch", input: { url: "https://veryfront.com/docs/agent/create-agent" }, - state: "input-available", + state: "output-error", + errorText: "tool error", }, ]); }); diff --git a/src/agent/react/use-chat/streaming/handler.test.ts b/src/agent/react/use-chat/streaming/handler.test.ts index 018a69350d..2dbff28f21 100644 --- a/src/agent/react/use-chat/streaming/handler.test.ts +++ b/src/agent/react/use-chat/streaming/handler.test.ts @@ -163,6 +163,51 @@ describe("use-chat streaming handler", () => { assertEquals(rec.toolCalls[0]!.toolCall.dynamic, true); }); + it("preserves providerExecuted on provider-owned input-only tool parts", async () => { + const rec = recorder(); + await handleStreamingResponse( + sseStream([ + { type: "message-start", messageId: "msg-provider-tool" }, + { + type: "tool-input-start", + toolCallId: "provider-fetch", + toolName: "web_fetch", + providerExecuted: true, + }, + { + type: "tool-input-available", + toolCallId: "provider-fetch", + toolName: "web_fetch", + input: { url: "https://example.com/docs" }, + providerExecuted: true, + }, + { type: "message-finish" }, + ]), + rec.callbacks, + ); + + assertEquals(rec.toolCalls[0]!.toolCall, { + toolCallId: "provider-fetch", + toolName: "web_fetch", + input: { url: "https://example.com/docs" }, + dynamic: false, + }); + const message = rec.messages[0]; + assertExists(message); + assertEquals(message.parts, [ + { + type: "tool-web_fetch", + toolCallId: "provider-fetch", + toolName: "web_fetch", + state: "input-available", + input: { url: "https://example.com/docs" }, + output: undefined, + errorText: undefined, + providerExecuted: true, + }, + ]); + }); + it("assembles reasoning blocks across deltas", async () => { const rec = recorder(); await handleStreamingResponse( @@ -303,6 +348,7 @@ describe("use-chat streaming handler", () => { input: { query: "agents" }, output: { count: 2 }, errorText: undefined, + providerExecuted: true, }, ]); }); diff --git a/src/agent/react/use-chat/streaming/handler.ts b/src/agent/react/use-chat/streaming/handler.ts index cdf2bfb962..d7106b2a29 100644 --- a/src/agent/react/use-chat/streaming/handler.ts +++ b/src/agent/react/use-chat/streaming/handler.ts @@ -459,6 +459,10 @@ function isRenderableDataPartType(type: string): boolean { type !== "data-messages-snapshot"; } +function getProviderExecuted(parsed: Record): boolean | undefined { + return typeof parsed.providerExecuted === "boolean" ? parsed.providerExecuted : undefined; +} + function handleToolInputStart( parsed: Record, state: StreamingState, @@ -470,12 +474,14 @@ function handleToolInputStart( } const toolCallId = (parsed.toolCallId as string) || generateClientId("tool"); + const providerExecuted = getProviderExecuted(parsed); const toolCall: OrderedToolCall = { toolCallId, toolName: (parsed.toolName as string) || "unknown", inputText: "", state: "input-streaming", dynamic: parsed.dynamic === true, + ...(providerExecuted !== undefined ? { providerExecuted } : {}), order: state.partOrderCounter++, }; @@ -511,6 +517,7 @@ function handleToolInputAvailable( } let toolCall = state.toolCalls.get(toolCallId); + const providerExecuted = getProviderExecuted(parsed); if (!toolCall) { toolCall = { toolCallId, @@ -518,6 +525,7 @@ function handleToolInputAvailable( inputText: "", state: "input-available", dynamic: parsed.dynamic === true, + ...(providerExecuted !== undefined ? { providerExecuted } : {}), order: state.partOrderCounter++, }; state.toolCalls.set(toolCallId, toolCall); @@ -527,6 +535,7 @@ function handleToolInputAvailable( toolCall.toolName = (parsed.toolName as string) || toolCall.toolName; toolCall.state = "input-available"; if (parsed.dynamic === true) toolCall.dynamic = true; + if (providerExecuted !== undefined) toolCall.providerExecuted = providerExecuted; onToolCall?.({ toolCall: { @@ -544,6 +553,9 @@ function handleToolInputAvailable( toolName: toolCall.toolName, state: "input-available", input: toolCall.input, + ...(toolCall.providerExecuted !== undefined + ? { providerExecuted: toolCall.providerExecuted } + : {}), }); } else { state.messageParts.push({ @@ -552,6 +564,9 @@ function handleToolInputAvailable( toolName: toolCall.toolName, state: "input-available", input: toolCall.input, + ...(toolCall.providerExecuted !== undefined + ? { providerExecuted: toolCall.providerExecuted } + : {}), } as ChatToolPart); } @@ -570,6 +585,8 @@ function handleToolOutputAvailable( toolCall.output = parsed.output; toolCall.state = "output-available"; + const providerExecuted = getProviderExecuted(parsed); + if (providerExecuted !== undefined) toolCall.providerExecuted = providerExecuted; state.messageParts.push({ type: "tool-result", @@ -594,6 +611,8 @@ function handleToolError( toolCall.state = "output-error"; toolCall.error = parsed.errorText as string; if (parsed.dynamic === true) toolCall.dynamic = true; + const providerExecuted = getProviderExecuted(parsed); + if (providerExecuted !== undefined) toolCall.providerExecuted = providerExecuted; emitUpdate(state, onUpdate, getBuildParts); } diff --git a/src/agent/react/use-chat/streaming/parts-builder.ts b/src/agent/react/use-chat/streaming/parts-builder.ts index 93e8ffefb8..575b421c4b 100644 --- a/src/agent/react/use-chat/streaming/parts-builder.ts +++ b/src/agent/react/use-chat/streaming/parts-builder.ts @@ -84,6 +84,7 @@ function addToolParts( input: tool.input, output: tool.output, errorText: tool.error, + ...(tool.providerExecuted !== undefined ? { providerExecuted: tool.providerExecuted } : {}), }; const part: ChatMessagePart = tool.dynamic diff --git a/src/agent/react/use-chat/streaming/types.ts b/src/agent/react/use-chat/streaming/types.ts index e29734019a..b8ba7cc427 100644 --- a/src/agent/react/use-chat/streaming/types.ts +++ b/src/agent/react/use-chat/streaming/types.ts @@ -21,6 +21,8 @@ export interface StreamingToolCall { state: ChatToolState; /** Whether this is a dynamic tool (MCP, user-defined, etc.) */ dynamic?: boolean; + /** Whether the provider executed this tool instead of the client/runtime. */ + providerExecuted?: boolean; } export interface StreamingReasoning { diff --git a/src/agent/streaming/chat-ui-message-stream.test.ts b/src/agent/streaming/chat-ui-message-stream.test.ts index 544f291b12..044e2816f1 100644 --- a/src/agent/streaming/chat-ui-message-stream.test.ts +++ b/src/agent/streaming/chat-ui-message-stream.test.ts @@ -4,6 +4,7 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import type { ChatUiMessageChunk } from "../../chat/types.ts"; import { type ChatUiMessageStreamFinish, + type ChatUiMessageStreamFinishPart, createChatUiMessageStreamFromDataStream, } from "./chat-ui-message-stream.ts"; @@ -130,6 +131,68 @@ describe("createChatUiMessageStreamFromDataStream", () => { }); }); + it("preserves providerExecuted from data stream tool events into final dynamic tool parts", async () => { + let finish: ChatUiMessageStreamFinish | undefined; + const chunks = await collectChunks( + createChatUiMessageStreamFromDataStream( + { + stream: createSseStream([ + { type: "message-start", messageId: "framework-message" }, + { + type: "tool-input-start", + toolCallId: "tool-provider-fetch", + toolName: "web_fetch", + providerExecuted: true, + }, + { + type: "tool-input-available", + toolCallId: "tool-provider-fetch", + toolName: "web_fetch", + input: { url: "https://example.com/docs" }, + providerExecuted: true, + }, + { type: "message-finish" }, + ]), + }, + { + generateMessageId: () => "assistant-message", + onFinish: (value) => { + finish = value; + }, + }, + ), + ); + + assertEquals(chunks, [ + { type: "start", messageId: "assistant-message" }, + { type: "start-step" }, + { + type: "tool-input-start", + toolCallId: "tool-provider-fetch", + toolName: "web_fetch", + providerExecuted: true, + }, + { + type: "tool-input-available", + toolCallId: "tool-provider-fetch", + toolName: "web_fetch", + input: { url: "https://example.com/docs" }, + providerExecuted: true, + }, + { type: "finish", finishReason: "stop" }, + ]); + assertEquals(finish?.responseMessage.parts, [ + { + type: "dynamic-tool", + toolName: "web_fetch", + toolCallId: "tool-provider-fetch", + input: { url: "https://example.com/docs" }, + providerExecuted: true, + state: "input-available", + }, + ]); + }); + it("carries runtime finish usage into final message metadata", async () => { let finish: | ChatUiMessageStreamFinish<{ @@ -145,13 +208,7 @@ describe("createChatUiMessageStreamFromDataStream", () => { costCredits?: number; }> | undefined; - let observedFinishPart: - | Parameters< - NonNullable< - Parameters[1] - >["messageMetadata"] - >[0]["part"] - | undefined; + let observedFinishPart: ChatUiMessageStreamFinishPart | undefined; const chunks = await collectChunks( createChatUiMessageStreamFromDataStream( diff --git a/src/agent/streaming/chat-ui-message-stream.ts b/src/agent/streaming/chat-ui-message-stream.ts index 069bc0f7fc..bfa464f8a6 100644 --- a/src/agent/streaming/chat-ui-message-stream.ts +++ b/src/agent/streaming/chat-ui-message-stream.ts @@ -89,6 +89,7 @@ type ToolPart = { inputText: string; input: Record; state: "input-available" | "output-available" | "output-error"; + providerExecuted?: boolean; output?: unknown; errorText?: string; }; @@ -250,8 +251,13 @@ function observeChatStreamEvent(input: { inputText: "", input: {}, state: "input-available", + ...(event.providerExecuted !== undefined + ? { providerExecuted: event.providerExecuted } + : {}), }); state.nextOrder += 1; + } else if (event.providerExecuted !== undefined) { + state.toolParts.get(event.toolCallId)!.providerExecuted = event.providerExecuted; } const pendingToolDelta = state.pendingToolDeltas.get(event.toolCallId); @@ -292,6 +298,9 @@ function observeChatStreamEvent(input: { toolPart.toolName = event.toolName; toolPart.input = input; toolPart.state = "input-available"; + if (event.providerExecuted !== undefined) { + toolPart.providerExecuted = event.providerExecuted; + } } else { state.toolParts.set(event.toolCallId, { toolCallId: event.toolCallId, @@ -300,6 +309,9 @@ function observeChatStreamEvent(input: { inputText: "", input, state: "input-available", + ...(event.providerExecuted !== undefined + ? { providerExecuted: event.providerExecuted } + : {}), }); state.nextOrder += 1; } @@ -313,6 +325,9 @@ function observeChatStreamEvent(input: { } toolPart.state = "output-available"; toolPart.output = event.output; + if (event.providerExecuted !== undefined) { + toolPart.providerExecuted = event.providerExecuted; + } return; } case "tool-output-error": @@ -321,6 +336,9 @@ function observeChatStreamEvent(input: { if (toolPart) { toolPart.state = "output-error"; toolPart.errorText = event.errorText; + if (event.providerExecuted !== undefined) { + toolPart.providerExecuted = event.providerExecuted; + } if ("input" in event && event.input !== undefined) { toolPart.input = parseToolInputObject(event.input); } @@ -335,6 +353,9 @@ function observeChatStreamEvent(input: { ? parseToolInputObject(event.input) : {}, state: "output-error", + ...(event.providerExecuted !== undefined + ? { providerExecuted: event.providerExecuted } + : {}), errorText: event.errorText, }); state.nextOrder += 1; @@ -421,12 +442,15 @@ function buildResponseMessageParts(state: FrameworkUiMessageState): ChatUiMessag for (const toolPart of state.toolParts.values()) { const basePart: Pick< ChatDynamicToolUiPart, - "type" | "toolName" | "toolCallId" | "input" + "type" | "toolName" | "toolCallId" | "input" | "providerExecuted" > = { type: "dynamic-tool", toolName: toolPart.toolName, toolCallId: toolPart.toolCallId, input: toolPart.input, + ...(toolPart.providerExecuted !== undefined + ? { providerExecuted: toolPart.providerExecuted } + : {}), }; const part: ChatUiMessage["parts"][number] = toolPart.state === "output-available" diff --git a/src/chat/conversation.test.ts b/src/chat/conversation.test.ts index e7acff05f3..5af5078a26 100644 --- a/src/chat/conversation.test.ts +++ b/src/chat/conversation.test.ts @@ -191,39 +191,57 @@ describe("chat/conversation helpers", () => { ]); }); - it("treats provider-native web tools as complete when the AI SDK omits providerExecuted", () => { + it("treats local web_fetch input-available tools as incomplete without providerExecuted", () => { const message: ChatUiMessage = { - id: "assistant-provider-native-tool", + id: "assistant-local-web-fetch", role: "assistant", parts: [ - { type: "text", text: "I can answer with the fetched context." }, + { type: "text", text: "Fetching the docs." }, { type: "tool-web_fetch", - toolCallId: "srvtoolu-fetch", + toolCallId: "local-fetch", input: { url: "https://veryfront.com/docs/agent/create-agent" }, state: "input-available", }, ], }; - assertEquals(hasIncompleteToolParts(message), false); - assertEquals(markIncompleteToolPartsAsErrored(message, "Tool call did not complete"), message); - assertEquals(toConversationPartsFromUiMessage(message), [ - { type: "text", text: "I can answer with the fetched context." }, - { - type: "tool_call", - id: "srvtoolu-fetch", - name: "web_fetch", - input: { url: "https://veryfront.com/docs/agent/create-agent" }, - state: "completed", - }, - { - type: "tool_result", - tool_call_id: "srvtoolu-fetch", - output: null, - is_error: false, - }, - ]); + assertEquals(hasIncompleteToolParts(message), true); + assertEquals(markIncompleteToolPartsAsErrored(message, "Tool call did not complete"), { + ...message, + parts: [ + { type: "text", text: "Fetching the docs." }, + { + type: "tool-web_fetch", + toolCallId: "local-fetch", + input: { url: "https://veryfront.com/docs/agent/create-agent" }, + state: "output-error", + errorText: "Tool call did not complete", + }, + ], + }); + assertEquals( + toConversationPartsFromUiMessage(markIncompleteToolPartsAsErrored( + message, + "Tool call did not complete", + )), + [ + { type: "text", text: "Fetching the docs." }, + { + type: "tool_call", + id: "local-fetch", + name: "web_fetch", + input: { url: "https://veryfront.com/docs/agent/create-agent" }, + state: "completed", + }, + { + type: "tool_result", + tool_call_id: "local-fetch", + output: "Tool call did not complete", + is_error: true, + }, + ], + ); }); it("maps UI messages into persistable conversation parts", () => { diff --git a/src/chat/conversation.ts b/src/chat/conversation.ts index ee9af63956..e3d6f9e103 100644 --- a/src/chat/conversation.ts +++ b/src/chat/conversation.ts @@ -197,8 +197,6 @@ export interface ReasoningPartLike { type ToolUiPart = Extract; type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; -const PROVIDER_NATIVE_WEB_TOOL_NAMES = new Set(["web_fetch", "web_search"]); - /** Shared UUID pattern value. */ export const UUID_PATTERN = /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i; @@ -305,7 +303,6 @@ export function getUiToolName(part: ToolUiPart): string | undefined { } function isProviderOwnedInputAvailableTool(input: { - toolName?: string; state: string; providerExecuted?: unknown; }): boolean { @@ -313,8 +310,7 @@ function isProviderOwnedInputAvailableTool(input: { return false; } - return input.providerExecuted === true || - (typeof input.toolName === "string" && PROVIDER_NATIVE_WEB_TOOL_NAMES.has(input.toolName)); + return input.providerExecuted === true; } /** Push tool parts. */ @@ -333,7 +329,6 @@ export function pushToolParts( const input = toRecord(part.input); const isErroredState = state === "output-error" || state === "error" || state === "output-denied"; const isProviderOwnedAvailable = isProviderOwnedInputAvailableTool({ - toolName, state, providerExecuted: part.providerExecuted, }); @@ -473,7 +468,6 @@ export function toConversationPartsFromUiMessage(message: ChatUiMessage): Messag function isToolComplete(part: ToolUiPart): boolean { if ( isProviderOwnedInputAvailableTool({ - toolName: getUiToolName(part), state: part.state, providerExecuted: part.providerExecuted, }) diff --git a/src/chat/protocol.ts b/src/chat/protocol.ts index 3945643aff..39bfd99faf 100644 --- a/src/chat/protocol.ts +++ b/src/chat/protocol.ts @@ -53,6 +53,8 @@ export interface ChatToolPart = IdChunk & { type ToolCallChunk = { type: TType; toolCallId: string; + providerExecuted?: boolean; + dynamic?: boolean; }; /** Public API contract for named tool call chunk. */