From 54bb0a9f6feb5d9f0e149aeea199aae075b59f38 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:32:18 -0500 Subject: [PATCH] feat(ai): allow async tools and add generateObject to the Promise client (#59445) --- packages/ai/README.md | 30 ++++++++---- packages/ai/src/index.ts | 51 ++++++++++++++++++- packages/ai/test/promise.test.ts | 84 +++++++++++++++++++++++++++++++- 3 files changed, 152 insertions(+), 13 deletions(-) diff --git a/packages/ai/README.md b/packages/ai/README.md index 6c25a189e42..45d69c2999c 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -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 diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 92119db7e1f..b033261270b 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -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>( + input: GenerateObjectOptions, + options?: RunOptions, + ): Promise>> + function generateObject( + input: GenerateObjectDynamicOptions, + options?: RunOptions, + ): Promise> + function generateObject( + input: GenerateObjectOptions> | GenerateObjectDynamicOptions, + options?: RunOptions, + ) { + return run("schema" in input ? LLM.generateObject(input) : LLM.generateObject(input), options) + } + const handle = (generation: Generation): GenerationHandle => ({ ...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: (input: LoopInput, 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 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 = >(config: { + readonly description: string + readonly parameters: Parameters + readonly execute: ( + params: Schema.Schema.Type, + context: Partial & { 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() diff --git a/packages/ai/test/promise.test.ts b/packages/ai/test/promise.test.ts index 0780c610bd2..df235c078f1 100644 --- a/packages/ai/test/promise.test.ts +++ b/packages/ai/test/promise.test.ts @@ -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 = [] + 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")