fix(llm): preserve provider-native continuation state

This commit is contained in:
Kit Langton
2026-05-03 12:47:03 -04:00
parent eea8764ce1
commit 6736923a35
5 changed files with 157 additions and 25 deletions
+11 -2
View File
@@ -108,6 +108,8 @@ Use `prepare(...)` to inspect the provider-native payload without sending it.
`Conversation` owns the shared stream-to-history semantics. It answers two questions: given the events from one model round, what assistant content and tool calls should be carried into the next request; and what did each raw event mean semantically?
```ts
import { Conversation } from "@opencode-ai/llm"
const state = Conversation.empty()
const deltas = Conversation.mutate(state, {
type: "tool-call",
@@ -138,8 +140,13 @@ const next = Conversation.continueRequest({
`defineTool(...)` bundles a description, parameter schema, success schema, and handler. The record key becomes the wire tool name.
```ts
import { Effect, Schema } from "effect"
import { LLM, OpenAIChat, ToolFailure, ToolRuntime, client, defineTool } from "@opencode-ai/llm"
import { Effect, Schema, Stream } from "effect"
import { LLM, OpenAIChat, RequestExecutor, ToolFailure, ToolRuntime, client, defineTool } from "@opencode-ai/llm"
const model = OpenAIChat.model({
id: "gpt-4o-mini",
apiKey: process.env.OPENAI_API_KEY,
})
const get_weather = defineTool({
description: "Get current weather for a city.",
@@ -163,6 +170,8 @@ const stream = ToolRuntime.run(client({ adapters: [OpenAIChat.adapter] }), {
tools: { get_weather },
maxSteps: 10,
})
const program = Stream.runCollect(stream).pipe(Effect.provide(RequestExecutor.defaultLayer))
```
Tool handlers should return typed success values or fail with `ToolFailure`. Unknown tools, invalid inputs, and invalid outputs become model-visible tool errors when they are recoverable.
@@ -632,10 +632,11 @@ const processChunk = (state: ParserState, chunk: BedrockChunk) =>
return [state, [{ type: "text-delta" as const, text: chunk.contentBlockDelta.delta.text }]] as const
}
if (chunk.contentBlockDelta?.delta?.reasoningContent?.text) {
if (chunk.contentBlockDelta?.delta?.reasoningContent) {
const reasoning = chunk.contentBlockDelta.delta.reasoningContent
return [
state,
[{ type: "reasoning-delta" as const, text: chunk.contentBlockDelta.delta.reasoningContent.text }],
[{ type: "reasoning-delta" as const, text: reasoning.text ?? "", encrypted: reasoning.signature }],
] as const
}
+75 -20
View File
@@ -13,6 +13,7 @@ import {
type TextPart,
type ToolCallPart,
type ToolDefinition,
type ToolResultPart,
} from "../schema"
import { ProviderShared } from "./shared"
@@ -33,6 +34,46 @@ const OpenAIResponsesOutputText = Schema.Struct({
text: Schema.String,
})
const HOSTED_TOOL_TYPES = [
"web_search_call",
"web_search_preview_call",
"file_search_call",
"code_interpreter_call",
"computer_use_call",
"image_generation_call",
"mcp_call",
"local_shell_call",
] as const
// item.type -> tool name. Each entry is the OpenAI Responses item type that
// represents a hosted (provider-executed) tool call.
const HOSTED_TOOL_NAMES = {
web_search_call: "web_search",
web_search_preview_call: "web_search_preview",
file_search_call: "file_search",
code_interpreter_call: "code_interpreter",
computer_use_call: "computer_use",
image_generation_call: "image_generation",
mcp_call: "mcp",
local_shell_call: "local_shell",
} satisfies Record<(typeof HOSTED_TOOL_TYPES)[number], string>
const OpenAIResponsesHostedToolItem = Schema.Struct({
type: Schema.Literals(HOSTED_TOOL_TYPES),
id: Schema.String,
status: Schema.optional(Schema.String),
action: Schema.optional(Schema.Unknown),
queries: Schema.optional(Schema.Unknown),
results: Schema.optional(Schema.Unknown),
code: Schema.optional(Schema.String),
container_id: Schema.optional(Schema.String),
outputs: Schema.optional(Schema.Unknown),
server_label: Schema.optional(Schema.String),
output: Schema.optional(Schema.Unknown),
error: Schema.optional(Schema.Unknown),
})
type OpenAIResponsesHostedToolItem = Schema.Schema.Type<typeof OpenAIResponsesHostedToolItem>
const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
Schema.Struct({ role: Schema.Literal("user"), content: Schema.Array(OpenAIResponsesInputText) }),
@@ -48,6 +89,7 @@ const OpenAIResponsesInputItem = Schema.Union([
call_id: Schema.String,
output: Schema.String,
}),
OpenAIResponsesHostedToolItem,
])
type OpenAIResponsesInputItem = Schema.Schema.Type<typeof OpenAIResponsesInputItem>
@@ -167,6 +209,25 @@ const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({
arguments: ProviderShared.encodeJson(part.input),
})
const decodeHostedToolItem = Schema.decodeUnknownEffect(OpenAIResponsesHostedToolItem)
const lowerHostedToolResult = Effect.fn("OpenAIResponses.lowerHostedToolResult")(function* (part: ToolResultPart) {
if (part.result.type !== "json") {
return yield* invalid(`OpenAI Responses hosted tool result for ${part.name} must be a JSON item`)
}
const item = yield* decodeHostedToolItem(part.result.value).pipe(Effect.mapError((error) => invalid(error.message)))
if (HOSTED_TOOL_NAMES[item.type] !== part.name) {
return yield* invalid(`OpenAI Responses hosted tool result ${item.type} does not match tool ${part.name}`)
}
return item
})
const flushAssistantText = (input: OpenAIResponsesInputItem[], content: TextPart[]) => {
if (content.length === 0) return
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
content.length = 0
}
const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) {
const system: OpenAIResponsesInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
@@ -191,13 +252,18 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
continue
}
if (part.type === "tool-call") {
input.push(lowerToolCall(part))
flushAssistantText(input, content)
if (!part.providerExecuted) input.push(lowerToolCall(part))
continue
}
return yield* invalid(`OpenAI Responses assistant messages only support text and tool-call content for now`)
if (part.type === "tool-result" && part.providerExecuted) {
flushAssistantText(input, content)
input.push(yield* lowerHostedToolResult(part))
continue
}
return yield* invalid(`OpenAI Responses assistant messages only support text, tool-call, and hosted tool-result content for now`)
}
if (content.length > 0)
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
flushAssistantText(input, content)
continue
}
@@ -268,22 +334,9 @@ const withoutTool = (tools: Record<string, ProviderShared.ToolAccumulator>, id:
// fields all in one item. We expose them as a `tool-call` + `tool-result` pair
// so consumers can treat them uniformly with client tools, only differentiated
// by `providerExecuted: true`.
//
// item.type → tool name. Each entry is the OpenAI Responses item type that
// represents a hosted (provider-executed) tool call.
const HOSTED_TOOL_NAMES: Record<string, string> = {
web_search_call: "web_search",
web_search_preview_call: "web_search_preview",
file_search_call: "file_search",
code_interpreter_call: "code_interpreter",
computer_use_call: "computer_use",
image_generation_call: "image_generation",
mcp_call: "mcp",
local_shell_call: "local_shell",
}
const isHostedToolItem = (item: OpenAIResponsesStreamItem): item is OpenAIResponsesStreamItem & { id: string } =>
item.type in HOSTED_TOOL_NAMES && typeof item.id === "string" && item.id.length > 0
const isHostedToolItem = (item: OpenAIResponsesStreamItem): item is OpenAIResponsesHostedToolItem =>
isHostedToolType(item.type) && typeof item.id === "string" && item.id.length > 0
// Pick the input fields the model actually populated when invoking the tool.
// The shape is tool-specific. Keep this list explicit so each tool's input is
@@ -307,7 +360,9 @@ const hostedToolResult = (item: OpenAIResponsesStreamItem) => {
: ({ type: "json" as const, value: item })
}
const hostedToolEvents = (item: OpenAIResponsesStreamItem & { id: string }): ReadonlyArray<LLMEvent> => {
const isHostedToolType = (type: string): type is keyof typeof HOSTED_TOOL_NAMES => type in HOSTED_TOOL_NAMES
const hostedToolEvents = (item: OpenAIResponsesHostedToolItem): ReadonlyArray<LLMEvent> => {
const name = HOSTED_TOOL_NAMES[item.type]
return [
{ type: "tool-call", id: item.id, name, input: hostedToolInput(item), providerExecuted: true },
@@ -212,7 +212,7 @@ describe("Bedrock Converse adapter", () => {
}),
)
it.effect("decodes reasoning deltas", () =>
it.effect("decodes reasoning deltas with signatures", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
@@ -220,6 +220,10 @@ describe("Bedrock Converse adapter", () => {
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } },
],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
],
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "end_turn" }],
)
@@ -228,6 +232,10 @@ describe("Bedrock Converse adapter", () => {
.pipe(Effect.provide(fixedBytes(body)))
expect(LLM.outputReasoning(response)).toBe("Let me think.")
expect(response.events.filter((event) => event.type === "reasoning-delta")).toEqual([
{ type: "reasoning-delta", text: "Let me think.", encrypted: undefined },
{ type: "reasoning-delta", text: "", encrypted: "sig_1" },
])
}),
)
@@ -119,6 +119,65 @@ describe("OpenAI Responses adapter", () => {
}),
)
it.effect("preserves assistant text and function call ordering", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.make({ adapters: [OpenAIResponses.adapter] }).prepare(
LLM.request({
id: "req_tool_order",
model,
messages: [
LLM.user("What is the weather?"),
LLM.assistant([
LLM.text("I will check."),
LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }),
LLM.text("Then I will answer."),
]),
],
}),
)
expect(prepared.target).toMatchObject({
input: [
{ role: "user", content: [{ type: "input_text", text: "What is the weather?" }] },
{ role: "assistant", content: [{ type: "output_text", text: "I will check." }] },
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
{ role: "assistant", content: [{ type: "output_text", text: "Then I will answer." }] },
],
})
}),
)
it.effect("round-trips hosted tool result items in assistant history", () =>
Effect.gen(function* () {
const item = {
type: "web_search_call",
id: "ws_1",
status: "completed",
action: { type: "search", query: "effect 4" },
}
const prepared = yield* LLMClient.make({ adapters: [OpenAIResponses.adapter] }).prepare(
LLM.request({
id: "req_hosted_history",
model,
messages: [
LLM.user("Search for Effect."),
LLM.assistant([
LLM.toolCall({ id: "ws_1", name: "web_search", input: item.action, providerExecuted: true }),
LLM.toolResult({ id: "ws_1", name: "web_search", result: item, providerExecuted: true }),
]),
],
}),
)
expect(prepared.target).toMatchObject({
input: [
{ role: "user", content: [{ type: "input_text", text: "Search for Effect." }] },
item,
],
})
}),
)
it.effect("parses text and usage stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(