mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(ai): allow async tools and add generateObject to the Promise client (#59445)
This commit is contained in:
committed by
GitHub
Unverified
parent
a28f95ad53
commit
54bb0a9f6f
+20
-10
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user