diff --git a/packages/llm/README.md b/packages/llm/README.md index 6d80bb8f11..f84d105c85 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -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. diff --git a/packages/llm/src/provider/bedrock-converse.ts b/packages/llm/src/provider/bedrock-converse.ts index e8ec2a9086..01761587e8 100644 --- a/packages/llm/src/provider/bedrock-converse.ts +++ b/packages/llm/src/provider/bedrock-converse.ts @@ -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 } diff --git a/packages/llm/src/provider/openai-responses.ts b/packages/llm/src/provider/openai-responses.ts index 47801561f3..9ef68ab30b 100644 --- a/packages/llm/src/provider/openai-responses.ts +++ b/packages/llm/src/provider/openai-responses.ts @@ -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 + 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 @@ -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, 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 = { - 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 => { +const isHostedToolType = (type: string): type is keyof typeof HOSTED_TOOL_NAMES => type in HOSTED_TOOL_NAMES + +const hostedToolEvents = (item: OpenAIResponsesHostedToolItem): ReadonlyArray => { const name = HOSTED_TOOL_NAMES[item.type] return [ { type: "tool-call", id: item.id, name, input: hostedToolInput(item), providerExecuted: true }, diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index 7c725e3fa1..de74de3526 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -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" }, + ]) }), ) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 186574c29e..3663a2fb63 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -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(