feat(opencode): plumb nativeTools through StreamInput (audit gap #4 phase 2 step 2a)

Adds opt-in `nativeTools?: ReadonlyArray<Tool.Def>` to `LLM.StreamInput`
so callers that route through the native path can attach typed
opencode tool definitions alongside the AI SDK `tools` record. The
gate in `runNative` widens accordingly: a session can use the native
path when it has zero tools (existing behavior) OR when it explicitly
provides `nativeTools` matching its AI SDK `tools` (new opt-in). When
`nativeTools` reaches `LLMNative.request`, the existing
`toolDefinition` converter folds each `Tool.Def` into the request's
`tools` array and the LLM core lowers it onto the wire.

This commit deliberately does NOT include the dispatch loop. A
session that opts in by setting `nativeTools` and that triggers a
`tool-call` from the model will see the call event but no
`tool-result` because the native path has no execute handler yet.
That's why no production caller populates `nativeTools`: phase 2
step 2b will land the dispatch loop and only then will real
production sessions route through here.

What this lays in place:

- `StreamInput.nativeTools` typed against `Tool.Def[]` from `@/tool`.
  Aliased to `OpenCodeTool` at the import to dodge a clash with the
  AI SDK `Tool` type that the same file already imports.
- The `runNative` gate flips from "no tools allowed" to "either no
  tools, or `nativeTools` is supplied". An AI SDK tool count > 0
  with `nativeTools` undefined still falls through, so existing
  production sessions are unaffected.
- `LLMNative.request` already accepted `tools: ReadonlyArray<Tool.Def>`
  and converts via `toolDefinition`. We just forward the input
  through; no LLM-bridge change.

Smoke coverage: a new test in `llm-native-stream.test.ts` builds a
typed `Tool.Def` (Effect Schema parameters), routes it through
`LLMNative.request` + `LLMClient.prepare`, and asserts the prepared
Anthropic target carries the tool as an `input_schema` block with
the expected JSON Schema shape. This validates the conversion path
that phase 2 step 2b will exercise from inside `runNative`.

Verification: opencode typecheck clean; 35/0/0 across the three
bridge-area tests (`llm-native.test.ts`, `llm-native-stream.test.ts`,
`llm-bridge.test.ts`).
This commit is contained in:
Kit Langton
2026-04-27 15:27:17 -04:00
parent afba37d330
commit fa8f7a1dca
2 changed files with 66 additions and 2 deletions
+17 -1
View File
@@ -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<MessageV2.WithParts>
// 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<OpenCodeTool.Def>
}
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
@@ -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<typeof lookupParameters> = {
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"],
},
},
],
})
}),
)
})