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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user