fix(llm): preserve provider-native continuation state
This commit is contained in:
+11
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user