Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline 5077422beb fix(ai): replay native hosted tool items 2026-08-25 12:00:42 -05:00
3 changed files with 111 additions and 4 deletions
+20 -2
View File
@@ -84,6 +84,11 @@ const OpenResponsesItemReference = Schema.Struct({
id: Schema.String,
})
const OpenResponsesHostedToolItem = Schema.StructWithRest(
Schema.Struct({ type: Schema.String.check(Schema.isPattern(/^(?!function_call$).+_call$/)), id: Schema.String }),
[Schema.Record(Schema.String, Schema.Unknown)],
)
// `function_call_output.output` accepts either a plain string or an ordered
// array of content items so tools can return images and files in addition to text.
// https://www.openresponses.org/reference
@@ -124,6 +129,7 @@ export const InputItem = Schema.Union([
call_id: Schema.String,
output: OpenResponsesFunctionCallOutput,
}),
OpenResponsesHostedToolItem,
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
type LoweredInputItem =
@@ -619,8 +625,20 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (store !== false && reference && !hostedToolReferences.has(reference))
input.push({ type: "item_reference", id: reference })
if (store === false) {
// The server is not storing this exchange, so the tool outcome has to
// travel in the input. Non-content results degrade to their text form.
if (
reference &&
part.result.type === "json" &&
ProviderShared.isRecord(part.result.value) &&
part.result.value.id === reference &&
typeof part.result.value.type === "string" &&
part.result.value.type !== "function_call" &&
part.result.value.type.endsWith("_call")
) {
input.push({ ...part.result.value, id: reference, type: part.result.value.type })
hostedToolReferences.add(reference)
continue
}
// Foreign-provider items and image results cannot be replayed natively.
const content: ReadonlyArray<Content> =
part.result.type === "content"
? part.result.value
@@ -2949,7 +2949,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues stateless hosted tool results with their text form", () =>
it.effect("replays stateless hosted tool results as native provider items", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -2981,6 +2981,41 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Search." }] },
{ type: "web_search_call", id: "ws_1", status: "completed" },
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
])
}),
)
it.effect("preserves foreign hosted tool results as portable message content", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: xaiModel,
messages: [
Message.assistant([
ToolCallPart.make({
id: "ws_1",
name: "web_search",
input: { query: "effect 4" },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
}),
{
type: "tool-result",
id: "ws_1",
name: "web_search",
result: { type: "json", value: { type: "web_search_call", id: "ws_1", status: "completed" } },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
},
]),
Message.user("Continue."),
],
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [{ type: "input_text", text: '{"type":"web_search_call","id":"ws_1","status":"completed"}' }],
@@ -2990,6 +3025,35 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("does not replay hosted tool items whose result id differs from provider metadata", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "tool-result",
id: "ws_1",
name: "web_search",
result: { type: "json", value: { type: "web_search_call", id: "ws_other", status: "completed" } },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
},
]),
],
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [{ type: "input_text", text: '{"type":"web_search_call","id":"ws_other","status":"completed"}' }],
},
])
}),
)
it.effect("drops replayed item ids outside the server's grammar", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent } from "../../src/index.js"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { XAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
@@ -106,6 +106,31 @@ describe("xAI Responses route", () => {
}),
)
it.effect("replays xAI hosted tool items when continuing with the same provider", () =>
Effect.gen(function* () {
const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } }
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "tool-result",
id: "x_search_1",
name: "x_search",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { xai: { itemId: "x_search_1" } },
},
]),
],
}),
)
expect(prepared.body.input).toEqual([item])
}),
)
it.effect("parses xAI hosted tool items", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search X" })).pipe(