feat(ai): allow async tools and add generateObject to the Promise client (#59445)

This commit is contained in:
Aiden Cline
2026-10-26 21:32:18 -05:00
committed by GitHub
Unverified
parent a28f95ad53
commit 54bb0a9f6f
3 changed files with 152 additions and 13 deletions
+20 -10
View File
@@ -55,22 +55,19 @@ Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployme
`stream` runs one model call; `loop` runs the model's tool calls and calls the model again until it stops calling
tools. Events from every step arrive on one stream, with a `tool-result` after each tool runs. `until` is checked
after each step that has tool calls, with every step's `LLMResponse`; returning `true` ends the loop without running
them:
them. `AI.tool` takes a plain `async` execute; a throw or rejection goes back to the model as an error result, and
`signal` aborts when the loop is interrupted.
`Tool.make` from `@opencode/ai/effect` is the Effect form, with a validated `success` schema:
```ts
import { readFile } from "node:fs/promises"
import { Effect, Schema } from "effect"
import { Tool, ToolFailure } from "@opencode/ai/effect"
import { Schema } from "effect"
import { AI } from "@opencode/ai"
const read_file = Tool.make({
const read_file = AI.tool({
description: "Read a UTF-8 file.",
parameters: Schema.Struct({ path: Schema.String }),
success: Schema.String,
execute: ({ path }) =>
Effect.tryPromise({
try: () => readFile(path, "utf8"),
catch: (error) => new ToolFailure({ message: `Failed to read ${path}`, error }),
}),
execute: ({ path }, { signal }) => readFile(path, { encoding: "utf8", signal }),
})
const events = ai.llm.loop({
@@ -84,6 +81,19 @@ for await (const event of events) {
}
```
### Structured output
`generateObject` forces a tool call and decodes its input against `schema`; pass `jsonSchema` instead for an
unvalidated `unknown` object:
```ts
const { object } = await ai.llm.generateObject({
model,
prompt: "Pick a city.",
schema: Schema.Struct({ city: Schema.String }),
})
```
### Raw provider events
Set `includeRaw: true` on a request to also receive every provider frame as `{ type: "raw", data }`, in order and
+49 -2
View File
@@ -1,4 +1,4 @@
import { Effect, Layer, ManagedRuntime, Stream } from "effect"
import { Effect, Layer, ManagedRuntime, Schema, Stream } from "effect"
import { AIClient } from "./ai-client.js"
import { Decision, type DecisionModel, type DecisionQuestions, type DecisionRequestInput } from "./decision.js"
import type { AwaitOptions, Event, Generation, Snapshot } from "./generation.js"
@@ -7,7 +7,13 @@ import { LLM } from "./effect.js"
import { Media } from "./media.js"
import { RequestExecutor } from "./route/executor.js"
import { AIError, InvalidRequestError, LanguageModel, type LLMRequest } from "./schema/index.js"
import type { LoopInput, RequestInput } from "./llm.js"
import type {
GenerateObjectDynamicOptions,
GenerateObjectOptions,
GenerateObjectResponse,
LoopInput,
RequestInput,
} from "./llm.js"
import { Speech, type SpeechModel, type SpeechRequest, type SpeechRequestInput } from "./speech.js"
import {
Transcription,
@@ -15,6 +21,7 @@ import {
type TranscriptionRequest,
type TranscriptionRequestInput,
} from "./transcription.js"
import { Tool, ToolFailure, type ToolExecuteContext, type ToolSchema } from "./tool.js"
import { fileMediaType } from "./utils/media-type.js"
import { Video, type VideoModel, type VideoRequest, type VideoRequestInput } from "./video.js"
@@ -83,6 +90,21 @@ export const make = (options: Options = {}) => {
),
)
function generateObject<const Model extends LanguageModel, S extends ToolSchema<any>>(
input: GenerateObjectOptions<S, Model>,
options?: RunOptions,
): Promise<GenerateObjectResponse<Schema.Schema.Type<S>>>
function generateObject<const Model extends LanguageModel>(
input: GenerateObjectDynamicOptions<Model>,
options?: RunOptions,
): Promise<GenerateObjectResponse<unknown>>
function generateObject(
input: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions,
options?: RunOptions,
) {
return run("schema" in input ? LLM.generateObject(input) : LLM.generateObject(input), options)
}
const handle = <Response>(generation: Generation<Response>): GenerationHandle<Response> => ({
...generation.snapshot,
token: generation.token,
@@ -140,6 +162,8 @@ export const make = (options: Options = {}) => {
/** Stream model calls, running their tool calls, until the model stops calling tools or `until` is true. */
loop: <const Model extends LanguageModel>(input: LoopInput<Model>, options?: RunOptions) =>
iterate(LLM.loop(input), options),
/** Decode the model's output against `schema` (or `jsonSchema`, unvalidated) like `LLM.generateObject`. */
generateObject,
},
image: {
request: Image.request,
@@ -215,6 +239,29 @@ export type Client = ReturnType<typeof make>
const fileError = (message: string, cause: unknown) =>
new AIError({ reason: new InvalidRequestError({ message, cause }) })
/**
* Promise counterpart of `Tool.make` for `ai.llm.loop`. A throw or rejection becomes the error result the model sees, the
* returned value is sent unvalidated (`undefined` as `null`), and `signal` aborts when the loop is interrupted.
*/
export const tool = <Parameters extends ToolSchema<any>>(config: {
readonly description: string
readonly parameters: Parameters
readonly execute: (
params: Schema.Schema.Type<Parameters>,
context: Partial<ToolExecuteContext> & { readonly signal: AbortSignal },
) => unknown
}) =>
Tool.make({
description: config.description,
parameters: config.parameters,
success: Schema.Unknown,
execute: (params, context) =>
Effect.tryPromise({
try: async (signal) => (await config.execute(params, { ...context, signal })) ?? null,
catch: (error) => new ToolFailure({ message: error instanceof Error ? error.message : String(error), error }),
}),
})
/** Default client over `RequestExecutor.fetchLayer` for scripts; the runtime builds its layer on first use. */
export const ai = make()
+83 -1
View File
@@ -2,13 +2,14 @@ import { describe, expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Effect, Layer } from "effect"
import { Effect, Layer, Schema } from "effect"
import { HttpClientRequest } from "effect/http"
import { AIError, LLMEvent, Media, SpeechEvent, TranscriptionEvent } from "../src/effect.js"
import { RequestExecutor } from "../src/route.js"
import { AI } from "../src/index.js"
import { AssemblyAI, OpenAI, Replicate, Runway } from "../src/providers.js"
import { handlerLayer, json } from "./lib/http.js"
import { finishChunk, toolCallChunk } from "./lib/openai-chunks.js"
import { sseEvents } from "./lib/sse.js"
const openai = OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" })
@@ -388,6 +389,87 @@ describe("AI promise client", () => {
await ai.dispose()
})
test("loops with async tools and sends rejections back to the model as error results", async () => {
const bodies: Array<string> = []
const ai = AI.make({
layer: RequestExecutor.layer.pipe(
Layer.provide(
handlerLayer((input) => {
bodies.push(input.text)
return Effect.succeed(
input.respond(
bodies.length === 1
? sseEvents(
toolCallChunk("call-1", "read_file", JSON.stringify({ path: "a.txt" })),
toolCallChunk("call-2", "read_file", JSON.stringify({ path: "missing.txt" }), 1),
toolCallChunk("call-3", "read_file", JSON.stringify({ path: "empty.txt" }), 2),
finishChunk("tool_calls"),
)
: chatBody,
{ headers: { "content-type": "text/event-stream" } },
),
)
}),
),
),
})
const read_file = AI.tool({
description: "Read a file.",
parameters: Schema.Struct({ path: Schema.String }),
execute: async ({ path }) => {
if (path === "missing.txt") throw new Error(`No such file: ${path}`)
if (path === "empty.txt") return
return "hello"
},
})
const events = await Array.fromAsync(
ai.llm.loop({ model: openai.chat("gpt-4o-mini"), prompt: "Read the files.", tools: { read_file } }),
)
expect(events.filter(LLMEvent.is.toolResult).map((event) => event.result)).toEqual([
{ type: "text", value: "hello" },
{ type: "error", value: "No such file: missing.txt" },
{ type: "json", value: null },
])
expect(JSON.parse(bodies[1]).messages.filter((message: { role: string }) => message.role === "tool")).toEqual([
expect.objectContaining({ tool_call_id: "call-1", content: "hello" }),
expect.objectContaining({ tool_call_id: "call-2", content: "No such file: missing.txt" }),
expect.objectContaining({ tool_call_id: "call-3", content: "null" }),
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["Hello", " world"])
await ai.dispose()
})
test("generates a decoded object", async () => {
const ai = AI.make({
layer: RequestExecutor.layer.pipe(
Layer.provide(
handlerLayer((input) =>
Effect.succeed(
input.respond(
sseEvents(
toolCallChunk("call-1", "generate_object", JSON.stringify({ city: "Paris" })),
finishChunk("tool_calls"),
),
{ headers: { "content-type": "text/event-stream" } },
),
),
),
),
),
})
const response = await ai.llm.generateObject({
model: openai.chat("gpt-4o-mini"),
prompt: "Pick a city.",
schema: Schema.Struct({ city: Schema.String }),
})
expect(response.object.city).toBe("Paris")
await ai.dispose()
})
test("the default client is created lazily and can be disposed", async () => {
expect(typeof AI.ai.llm.generate).toBe("function")
expect(typeof AI.ai.image.generate).toBe("function")