Skip to content

Commit eea6401

Browse files
committed
feat(session): execute tools in native LLM runtime
1 parent ada2906 commit eea6401

4 files changed

Lines changed: 195 additions & 28 deletions

File tree

packages/llm/src/tool-runtime.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -200,17 +200,17 @@ const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<ToolResultVal
200200
if (!tool.execute)
201201
return Effect.succeed({ type: "error" as const, value: `Tool has no execute handler: ${call.name}` })
202202

203-
return decodeAndExecute(tool, call.input).pipe(
203+
return decodeAndExecute(tool, call).pipe(
204204
Effect.catchTag("LLM.ToolFailure", (failure) =>
205205
Effect.succeed({ type: "error" as const, value: failure.message } satisfies ToolResultValue),
206206
),
207207
)
208208
}
209209

210-
const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect<ToolResultValue, ToolFailure> =>
211-
tool._decode(input).pipe(
210+
const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect<ToolResultValue, ToolFailure> =>
211+
tool._decode(call.input).pipe(
212212
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
213-
Effect.flatMap((decoded) => tool.execute!(decoded)),
213+
Effect.flatMap((decoded) => tool.execute!(decoded, { id: call.id, name: call.name })),
214214
Effect.flatMap((value) =>
215215
tool._encode(value).pipe(
216216
Effect.mapError(

packages/llm/src/tool.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type ToolSchema<T> = Schema.Codec<T, any, never, never>
1111

1212
export type ToolExecute<Parameters extends ToolSchema<any>, Success extends ToolSchema<any>> = (
1313
params: Schema.Schema.Type<Parameters>,
14+
context?: { readonly id: string; readonly name: string },
1415
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
1516

1617
/**
@@ -61,7 +62,10 @@ type TypedToolConfig = {
6162
type DynamicToolConfig = {
6263
readonly description: string
6364
readonly jsonSchema: JsonSchema.JsonSchema
64-
readonly execute?: (params: unknown) => Effect.Effect<unknown, ToolFailure>
65+
readonly execute?: (
66+
params: unknown,
67+
context?: { readonly id: string; readonly name: string },
68+
) => Effect.Effect<unknown, ToolFailure>
6569
}
6670

6771
/**
@@ -110,7 +114,10 @@ export function make<Parameters extends ToolSchema<any>, Success extends ToolSch
110114
export function make(config: {
111115
readonly description: string
112116
readonly jsonSchema: JsonSchema.JsonSchema
113-
readonly execute: (params: unknown) => Effect.Effect<unknown, ToolFailure>
117+
readonly execute: (
118+
params: unknown,
119+
context?: { readonly id: string; readonly name: string },
120+
) => Effect.Effect<unknown, ToolFailure>
114121
}): AnyExecutableTool
115122
export function make(config: {
116123
readonly description: string

packages/opencode/src/session/llm.ts

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { Provider } from "@/provider/provider"
22
import * as Log from "@opencode-ai/core/util/log"
33
import { Context, Effect, Layer, Record } from "effect"
44
import * as Stream from "effect/Stream"
5-
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
6-
import type { LLMEvent } from "@opencode-ai/llm"
5+
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool as aiTool, jsonSchema, asSchema } from "ai"
6+
import { tool as nativeTool, ToolFailure, type JsonSchema, type LLMEvent } from "@opencode-ai/llm"
77
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
88
import { mergeDeep } from "remeda"
99
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
@@ -18,6 +18,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
1818
import { Permission } from "@/permission"
1919
import { PermissionID } from "@/permission/schema"
2020
import { Bus } from "@/bus"
21+
import { errorMessage } from "@/util/error"
2122
import { Wildcard } from "@/util/wildcard"
2223
import { SessionID } from "@/session/schema"
2324
import { Auth } from "@/auth"
@@ -216,7 +217,7 @@ const live: Layer.Layer<
216217
Object.keys(tools).length === 0 &&
217218
hasToolCalls(input.messages)
218219
) {
219-
tools["_noop"] = tool({
220+
tools["_noop"] = aiTool({
220221
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
221222
inputSchema: jsonSchema({
222223
type: "object",
@@ -358,31 +359,31 @@ const live: Layer.Layer<
358359
if (input.model.providerID !== "openai" || input.model.api.npm !== "@ai-sdk/openai") {
359360
return yield* Effect.fail(new Error("Native LLM runtime currently only supports OpenAI models"))
360361
}
361-
if (Object.keys(sortedTools).length > 0) {
362-
return yield* Effect.fail(new Error("Native LLM runtime does not support tools yet"))
363-
}
364362
const apiKey =
365363
info?.type === "api" ? info.key : typeof item.options.apiKey === "string" ? item.options.apiKey : undefined
366364
if (!apiKey) return yield* Effect.fail(new Error("Native LLM runtime requires API key auth for OpenAI"))
367365
const baseURL = typeof item.options.baseURL === "string" ? item.options.baseURL : undefined
366+
const request = LLMNative.request({
367+
model: input.model,
368+
apiKey,
369+
baseURL,
370+
system: isOpenaiOauth ? system : [],
371+
messages: ProviderTransform.message(messages, input.model, options),
372+
tools: sortedTools,
373+
toolChoice: input.toolChoice,
374+
temperature: params.temperature,
375+
topP: params.topP,
376+
topK: params.topK,
377+
maxOutputTokens: params.maxOutputTokens,
378+
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
379+
headers: requestHeaders,
380+
})
368381
return {
369382
type: "native" as const,
370-
stream: LLMClient.stream(
371-
LLMNative.request({
372-
model: input.model,
373-
apiKey,
374-
baseURL,
375-
system: isOpenaiOauth ? system : [],
376-
messages: ProviderTransform.message(messages, input.model, options),
377-
toolChoice: input.toolChoice,
378-
temperature: params.temperature,
379-
topP: params.topP,
380-
topK: params.topK,
381-
maxOutputTokens: params.maxOutputTokens,
382-
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
383-
headers: requestHeaders,
384-
}),
385-
).pipe(Stream.provide(LLMClient.layer), Stream.provide(RequestExecutor.defaultLayer)),
383+
stream: LLMClient.stream({ request, tools: nativeTools(sortedTools, input) }).pipe(
384+
Stream.provide(LLMClient.layer),
385+
Stream.provide(RequestExecutor.defaultLayer),
386+
),
386387
}
387388
}
388389

@@ -502,6 +503,37 @@ function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission"
502503
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
503504
}
504505

506+
function nativeSchema(value: unknown): JsonSchema {
507+
if (!value || typeof value !== "object") return { type: "object", properties: {} }
508+
if ("jsonSchema" in value && value.jsonSchema && typeof value.jsonSchema === "object")
509+
return value.jsonSchema as JsonSchema
510+
return asSchema(value as Parameters<typeof asSchema>[0]).jsonSchema as JsonSchema
511+
}
512+
513+
function nativeTools(tools: Record<string, Tool>, input: StreamRequest) {
514+
return Object.fromEntries(
515+
Object.entries(tools).map(([name, item]) => [
516+
name,
517+
nativeTool({
518+
description: item.description ?? "",
519+
jsonSchema: nativeSchema(item.inputSchema),
520+
execute: (args: unknown, ctx?: { readonly id: string; readonly name: string }) =>
521+
Effect.tryPromise({
522+
try: () => {
523+
if (!item.execute) throw new Error(`Tool has no execute handler: ${name}`)
524+
return item.execute(args, {
525+
toolCallId: ctx?.id ?? name,
526+
messages: input.messages,
527+
abortSignal: input.abort,
528+
})
529+
},
530+
catch: (error) => new ToolFailure({ message: errorMessage(error) }),
531+
}),
532+
}),
533+
]),
534+
)
535+
}
536+
505537
// Check if messages contain any tool-call content
506538
// Used to determine if a dummy tool should be added for LiteLLM proxy compatibility
507539
export function hasToolCalls(messages: ModelMessage[]): boolean {

packages/opencode/test/session/llm.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -802,6 +802,134 @@ describe("session.llm.stream", () => {
802802
})
803803
})
804804

805+
test("executes OpenAI tool calls through native runtime", async () => {
806+
const server = state.server
807+
if (!server) {
808+
throw new Error("Server not initialized")
809+
}
810+
811+
const source = await loadFixture("openai", "gpt-5.2")
812+
const model = source.model
813+
const chunks = [
814+
{
815+
type: "response.output_item.added",
816+
item: { type: "function_call", id: "item-native-tool", call_id: "call-native-tool", name: "lookup" },
817+
},
818+
{
819+
type: "response.function_call_arguments.delta",
820+
item_id: "item-native-tool",
821+
delta: '{"query":"weather"}',
822+
},
823+
{
824+
type: "response.output_item.done",
825+
item: {
826+
type: "function_call",
827+
id: "item-native-tool",
828+
call_id: "call-native-tool",
829+
name: "lookup",
830+
arguments: '{"query":"weather"}',
831+
},
832+
},
833+
{
834+
type: "response.completed",
835+
response: { incomplete_details: null, usage: { input_tokens: 1, output_tokens: 1 } },
836+
},
837+
]
838+
const request = waitRequest("/responses", createEventResponse(chunks, true))
839+
let executed: unknown
840+
841+
await using tmp = await tmpdir({
842+
init: async (dir) => {
843+
await Bun.write(
844+
path.join(dir, "opencode.json"),
845+
JSON.stringify({
846+
$schema: "https://opencode.ai/config.json",
847+
enabled_providers: ["openai"],
848+
provider: {
849+
openai: {
850+
name: "OpenAI",
851+
env: ["OPENAI_API_KEY"],
852+
npm: "@ai-sdk/openai",
853+
api: "https://api.openai.com/v1",
854+
models: {
855+
[model.id]: model,
856+
},
857+
options: {
858+
apiKey: "test-openai-key",
859+
baseURL: `${server.url.origin}/v1`,
860+
},
861+
},
862+
},
863+
}),
864+
)
865+
},
866+
})
867+
868+
await WithInstance.provide({
869+
directory: tmp.path,
870+
fn: async () => {
871+
const previous = process.env.OPENCODE_LLM_RUNTIME
872+
process.env.OPENCODE_LLM_RUNTIME = "native"
873+
try {
874+
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id))
875+
const sessionID = SessionID.make("session-test-native-tool")
876+
const agent = {
877+
name: "test",
878+
mode: "primary",
879+
options: {},
880+
permission: [{ permission: "*", pattern: "*", action: "allow" }],
881+
} satisfies Agent.Info
882+
883+
await drain({
884+
user: {
885+
id: MessageID.make("msg_user-native-tool"),
886+
sessionID,
887+
role: "user",
888+
time: { created: Date.now() },
889+
agent: agent.name,
890+
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
891+
} satisfies MessageV2.User,
892+
sessionID,
893+
model: resolved,
894+
agent,
895+
system: [],
896+
messages: [{ role: "user", content: "Use lookup" }],
897+
tools: {
898+
lookup: tool({
899+
description: "Lookup data",
900+
inputSchema: z.object({ query: z.string() }),
901+
execute: async (args, options) => {
902+
executed = { args, toolCallId: options.toolCallId }
903+
return { output: "looked up" }
904+
},
905+
}),
906+
},
907+
})
908+
} finally {
909+
if (previous === undefined) delete process.env.OPENCODE_LLM_RUNTIME
910+
else process.env.OPENCODE_LLM_RUNTIME = previous
911+
}
912+
913+
const capture = await request
914+
expect(capture.body.tools).toEqual([
915+
{
916+
type: "function",
917+
name: "lookup",
918+
description: "Lookup data",
919+
parameters: {
920+
type: "object",
921+
properties: { query: { type: "string" } },
922+
required: ["query"],
923+
additionalProperties: false,
924+
$schema: "http://json-schema.org/draft-07/schema#",
925+
},
926+
},
927+
])
928+
expect(executed).toEqual({ args: { query: "weather" }, toolCallId: "call-native-tool" })
929+
},
930+
})
931+
})
932+
805933
test("accepts user image attachments as data URLs for OpenAI models", async () => {
806934
const server = state.server
807935
if (!server) {

0 commit comments

Comments
 (0)