diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 8bb876e460..49bb014327 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -22,6 +22,8 @@ import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" import type { Agent } from "@/agent/agent" import type { MessageV2 } from "./message-v2" +// Aliased to avoid a name clash with the AI SDK `Tool` type imported above. +import type { Tool as OpenCodeTool } from "@/tool" import { Plugin } from "@/plugin" import { SystemPrompt } from "./system" import { Flag } from "@opencode-ai/core/flag/flag" @@ -60,6 +62,13 @@ export type StreamInput = { retries?: number toolChoice?: "auto" | "required" | "none" nativeMessages?: ReadonlyArray + // Opcode-native `Tool.Def[]` parallel to `tools` (AI SDK shape). When + // populated alongside `tools`, the LLM-native path forwards definitions to + // the model. Dispatch + multi-round tool loops land in Phase 2 step 2b; for + // now the request can carry tools but the gate keeps real production tool + // sessions on the AI SDK path because no production caller populates this + // field yet. + nativeTools?: ReadonlyArray } export type StreamRequest = StreamInput & { @@ -478,7 +487,13 @@ const live: Layer.Layer< const runNative = Effect.fn("LLM.runNative")(function* (input: StreamRequest) { if (!Flag.OPENCODE_EXPERIMENTAL_LLM_NATIVE) return undefined if (!input.nativeMessages || input.nativeMessages.length === 0) return undefined - if (Object.keys(input.tools).length > 0) return undefined + // Tools without dispatch wiring would mean the model issues tool-call + // events that never get a tool-result. The gate fall-through keeps + // tool-using sessions on the AI SDK path until step 2b lands the + // dispatch loop. Sessions with zero tools, OR sessions that explicitly + // opt in by populating `nativeTools`, can route here. + const hasAITools = Object.keys(input.tools).length > 0 + if (hasAITools && (input.nativeTools === undefined || input.nativeTools.length === 0)) return undefined const item = yield* provider.getProvider(input.model.providerID) const llmRequest = yield* LLMNative.request({ @@ -487,6 +502,7 @@ const live: Layer.Layer< model: input.model, system: input.system, messages: input.nativeMessages, + tools: input.nativeTools, }) if (!NATIVE_PROTOCOLS.has(llmRequest.model.protocol)) return undefined diff --git a/packages/opencode/test/session/llm-native-stream.test.ts b/packages/opencode/test/session/llm-native-stream.test.ts index 75bbba7767..5809abb503 100644 --- a/packages/opencode/test/session/llm-native-stream.test.ts +++ b/packages/opencode/test/session/llm-native-stream.test.ts @@ -10,7 +10,7 @@ import { ProviderPatch, RequestExecutor, } from "@opencode-ai/llm" -import { Effect, Layer, Stream } from "effect" +import { Effect, Layer, Schema, Stream } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { ModelID, ProviderID } from "../../src/provider/schema" import { MessageID, PartID, SessionID } from "../../src/session/schema" @@ -20,6 +20,7 @@ import { ProviderTest } from "../fake/provider" import { testEffect } from "../lib/effect" import type { MessageV2 } from "../../src/session/message-v2" import type { Provider } from "../../src/provider" +import type { Tool } from "../../src/tool" // Inline HTTP layer that returns a single fixed body. Mirrors the // `fixedResponse` helper in `packages/llm/test/lib/http.ts` — duplicated here @@ -148,4 +149,51 @@ describe("LLMNative stream wire-up (audit gap #4 phase 1)", () => { expect(collected.some((event) => event.type === "error")).toBe(false) }), ) + + // Phase 2 step 2a: verifies a tool-bearing `nativeTools` array reaches the + // wire as Anthropic `tools[]` blocks. The model in this fixture answers with + // plain text instead of issuing a tool call (we don't yet have dispatch). + // This proves tool definitions plumb through `LLMNative.request` → + // `LLMRequest` → adapter `prepare` → wire body. + it.effect("forwards nativeTools to the wire as Anthropic tools when the gate is open", () => + Effect.gen(function* () { + const mdl = anthropicModel() + const provider = ProviderTest.info({ id: ProviderID.make("anthropic"), key: "anthropic-key" }, mdl) + const userID = MessageID.ascending() + + const lookupParameters = Schema.Struct({ + query: Schema.String.annotate({ description: "Search query" }), + }) + const lookupTool: Tool.Def = { + id: "lookup", + description: "Lookup project data", + parameters: lookupParameters, + execute: () => Effect.succeed({ title: "", metadata: {}, output: "" }), + } + + const llmRequest = yield* LLMNative.request({ + id: "smoke-tools", + provider, + model: mdl, + system: ["You are concise."], + messages: [userMessage(mdl, userID, [userPart(userID, "Look something up.")])], + tools: [lookupTool], + }) + + const prepared = yield* LLMClient.make({ adapters, patches: ProviderPatch.defaults }).prepare(llmRequest) + expect(prepared.target).toMatchObject({ + tools: [ + { + name: "lookup", + description: "Lookup project data", + input_schema: { + type: "object", + properties: { query: { type: "string", description: "Search query" } }, + required: ["query"], + }, + }, + ], + }) + }), + ) })