Skip to content

Commit fb92777

Browse files
Apply PR #27114: Preview native LLM runtime stack
2 parents fb90827 + cb2577b commit fb92777

22 files changed

Lines changed: 2091 additions & 520 deletions

bun.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/llm/src/schema/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,5 +198,6 @@ export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
198198
*/
199199
export class ToolFailure extends Schema.TaggedErrorClass<ToolFailure>()("LLM.ToolFailure", {
200200
message: Schema.String,
201+
error: Schema.optional(Schema.Unknown),
201202
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
202203
}) {}

packages/llm/src/schema/events.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ export const ToolError = Schema.Struct({
171171
id: ToolCallID,
172172
name: Schema.String,
173173
message: Schema.String,
174+
error: Schema.optional(Schema.Unknown),
174175
providerMetadata: Schema.optional(ProviderMetadata),
175176
}).annotate({ identifier: "LLM.Event.ToolError" })
176177
export type ToolError = Schema.Schema.Type<typeof ToolError>

packages/llm/src/tool-runtime.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -112,17 +112,29 @@ export const stream = <T extends Tools>(options: StreamOptions<T>): Stream.Strea
112112

113113
const dispatched = yield* Effect.forEach(
114114
state.toolCalls,
115-
(call) => dispatch(tools, call).pipe(Effect.map((result) => [call, result] as const)),
115+
(call) =>
116+
dispatch(tools, call).pipe(Effect.map((result) => [call, result.result, result.error] as const)),
116117
{ concurrency },
117118
)
118-
const resultStream = Stream.fromIterable(dispatched.flatMap(([call, result]) => emitEvents(call, result)))
119+
const resultStream = Stream.fromIterable(
120+
dispatched.flatMap(([call, result, error]) => emitEvents(call, result, error)),
121+
)
119122

120123
if (!options.stopWhen) return resultStream.pipe(Stream.concat(finishStream))
121124
if (options.stopWhen({ step, request })) return resultStream.pipe(Stream.concat(finishStream))
122125

123126
return resultStream.pipe(
124127
Stream.concat(
125-
loop(followUpRequest(request, state, dispatched), step + 1, totalUsage, totalProviderMetadata),
128+
loop(
129+
followUpRequest(
130+
request,
131+
state,
132+
dispatched.map(([call, result]) => [call, result] as const),
133+
),
134+
step + 1,
135+
totalUsage,
136+
totalProviderMetadata,
137+
),
126138
),
127139
)
128140
}),
@@ -215,7 +227,7 @@ const addUsage = (left: Usage | undefined, right: Usage | undefined) => {
215227
| "reasoningTokens"
216228
| "totalTokens"
217229
const sum = (key: UsageKey) =>
218-
left[key] === undefined && right[key] === undefined ? undefined : Number(left[key] ?? 0) + Number(right[key] ?? 0)
230+
left[key] === undefined && right[key] === undefined ? undefined : (left[key] ?? 0) + (right[key] ?? 0)
219231

220232
return new Usage({
221233
inputTokens: sum("inputTokens"),
@@ -264,16 +276,20 @@ const appendStreamingText = (
264276
state.assistantContent.push({ type, text, providerMetadata })
265277
}
266278

267-
const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<ToolResultValue> => {
279+
const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<{ result: ToolResultValue; error?: unknown }> => {
268280
const tool = tools[call.name]
269-
if (!tool) return Effect.succeed({ type: "error" as const, value: `Unknown tool: ${call.name}` })
281+
if (!tool) return Effect.succeed({ result: { type: "error" as const, value: `Unknown tool: ${call.name}` } })
270282
if (!tool.execute)
271-
return Effect.succeed({ type: "error" as const, value: `Tool has no execute handler: ${call.name}` })
283+
return Effect.succeed({ result: { type: "error" as const, value: `Tool has no execute handler: ${call.name}` } })
272284

273285
return decodeAndExecute(tool, call).pipe(
274286
Effect.catchTag("LLM.ToolFailure", (failure) =>
275-
Effect.succeed({ type: "error" as const, value: failure.message } satisfies ToolResultValue),
287+
Effect.succeed({
288+
result: { type: "error" as const, value: failure.message } satisfies ToolResultValue,
289+
error: failure.error,
290+
}),
276291
),
292+
Effect.map((result) => ("result" in result ? result : { result })),
277293
)
278294
}
279295

@@ -294,10 +310,10 @@ const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect<Tool
294310
Effect.map((encoded): ToolResultValue => ({ type: "json", value: encoded })),
295311
)
296312

297-
const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray<LLMEvent> =>
313+
const emitEvents = (call: ToolCallPart, result: ToolResultValue, error: unknown): ReadonlyArray<LLMEvent> =>
298314
result.type === "error"
299315
? [
300-
LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value) }),
316+
LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value), error }),
301317
LLMEvent.toolResult({ id: call.id, name: call.name, result }),
302318
]
303319
: [LLMEvent.toolResult({ id: call.id, name: call.name, result })]

packages/llm/test/tool-runtime.test.ts

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,16 @@ const baseRequest = LLM.request({
2525
model,
2626
prompt: "Use the tool.",
2727
})
28+
const weatherFailureCause = new Error("weather lookup denied")
2829

2930
const get_weather = tool({
3031
description: "Get current weather for a city.",
3132
parameters: Schema.Struct({ city: Schema.String }),
3233
success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
3334
execute: ({ city }) =>
3435
Effect.gen(function* () {
35-
if (city === "FAIL") return yield* new ToolFailure({ message: `Weather lookup failed for ${city}` })
36+
if (city === "FAIL")
37+
return yield* new ToolFailure({ message: `Weather lookup failed for ${city}`, error: weatherFailureCause })
3638
return { temperature: 22, condition: "sunny" }
3739
}),
3840
})
@@ -85,23 +87,27 @@ describe("LLMClient tools", () => {
8587
tools: { get_weather },
8688
}).pipe(Stream.runCollect, Effect.provide(layer))
8789

88-
const second = bodies[1] as {
89-
readonly messages?: ReadonlyArray<Record<string, unknown>>
90-
readonly tools?: ReadonlyArray<unknown>
91-
readonly tool_choice?: unknown
92-
readonly max_tokens?: unknown
93-
}
94-
95-
expect(second.max_tokens).toBe(50)
96-
expect(second.tool_choice).toBe("auto")
97-
expect(second.tools).toHaveLength(1)
98-
expect(second.messages?.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
99-
expect(second.messages?.[1]).toMatchObject({
90+
const second = bodies[1]
91+
if (!second || typeof second !== "object") throw new Error("Expected second request body")
92+
const messages = Reflect.get(second, "messages")
93+
const tools = Reflect.get(second, "tools")
94+
95+
expect(Reflect.get(second, "max_tokens")).toBe(50)
96+
expect(Reflect.get(second, "tool_choice")).toBe("auto")
97+
expect(tools).toHaveLength(1)
98+
expect(
99+
Array.isArray(messages)
100+
? messages.map((message) =>
101+
message && typeof message === "object" ? Reflect.get(message, "role") : undefined,
102+
)
103+
: undefined,
104+
).toEqual(["user", "assistant", "tool"])
105+
expect(Array.isArray(messages) ? messages[1] : undefined).toMatchObject({
100106
role: "assistant",
101107
content: null,
102108
tool_calls: [{ id: "call_1", type: "function", function: { name: "get_weather" } }],
103109
})
104-
expect(second.messages?.[2]).toMatchObject({
110+
expect(Array.isArray(messages) ? messages[2] : undefined).toMatchObject({
105111
role: "tool",
106112
tool_call_id: "call_1",
107113
content: '{"temperature":22,"condition":"sunny"}',
@@ -327,6 +333,7 @@ describe("LLMClient tools", () => {
327333
const toolError = events.find(LLMEvent.is.toolError)
328334
expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" })
329335
expect(toolError?.message).toBe("Weather lookup failed for FAIL")
336+
expect(toolError?.error).toBe(weatherFailureCause)
330337
}),
331338
)
332339

packages/opencode/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@
4242
"devDependencies": {
4343
"@babel/core": "7.28.4",
4444
"@octokit/webhooks-types": "7.6.1",
45-
"@opencode-ai/script": "workspace:*",
4645
"@opencode-ai/core": "workspace:*",
46+
"@opencode-ai/http-recorder": "workspace:*",
47+
"@opencode-ai/script": "workspace:*",
4748
"@parcel/watcher-darwin-arm64": "2.5.1",
4849
"@parcel/watcher-darwin-x64": "2.5.1",
4950
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
@@ -104,6 +105,7 @@
104105
"@octokit/graphql": "9.0.2",
105106
"@octokit/rest": "catalog:",
106107
"@openauthjs/openauth": "catalog:",
108+
"@opencode-ai/llm": "workspace:*",
107109
"@opencode-ai/plugin": "workspace:*",
108110
"@opencode-ai/script": "workspace:*",
109111
"@opencode-ai/sdk": "workspace:*",

packages/opencode/src/effect/runtime-flags.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
2424
experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
2525
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
2626
experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
27+
experimentalNativeLlm: Config.all({
28+
enabled: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
29+
legacy: Config.string("OPENCODE_LLM_RUNTIME").pipe(Config.withDefault("")),
30+
}).pipe(Config.map((flags) => flags.enabled || flags.legacy === "native")),
2731
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
2832
}) {}
2933

0 commit comments

Comments
 (0)