Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/agent/ag-ui/runtime-chat-stream-encoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 42 additions & 4 deletions src/agent/ag-ui/runtime-chat-stream-encoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ type ToolPart = {
toolName: string;
inputText: string;
input: Record<string, unknown>;
providerExecuted?: boolean;
};

type PendingToolDelta = {
Expand Down Expand Up @@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -453,29 +468,40 @@ 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({
type: "tool-input-available",
toolCallId,
toolName,
input: resolvedInputRecord,
...(providerExecuted !== undefined ? { providerExecuted } : {}),
});
return events;
}
Expand All @@ -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": {
Expand All @@ -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": {
Expand Down
3 changes: 2 additions & 1 deletion src/agent/hosted/chat-execution-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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,
},
],
}),
Expand Down
161 changes: 161 additions & 0 deletions src/agent/hosted/chat-runtime-tool-assembly.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ReturnType<typeof localTool>>;
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 = () =>
Expand Down
Loading