Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions packages/ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,16 @@ 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. `AI.tool` takes a plain `async` execute; a throw or rejection goes back to the model as an error result, and
them. `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:
`tool` from `@opencode/ai/effect` takes the same options (including an optional `success` schema) with an Effect execute:

```ts
import { readFile } from "node:fs/promises"
import { Schema } from "effect"
import { AI } from "@opencode/ai"
import { tool } from "@opencode/ai"

const read_file = AI.tool({
const read_file = tool({
description: "Read a UTF-8 file.",
parameters: Schema.Struct({ path: Schema.String }),
execute: ({ path }, { signal }) => readFile(path, { encoding: "utf8", signal }),
Expand All @@ -87,7 +87,7 @@ A tool with exactly one required string parameter can set `freeform`, so the mod
instead of JSON arguments: unconstrained `text`, or text matching a `regex` or `lark` grammar.

```ts
const run_code = AI.tool({
const run_code = tool({
description: "Run JavaScript",
parameters: Schema.Struct({ code: Schema.String }),
freeform: { type: "lark", grammar: JS_GRAMMAR },
Expand Down Expand Up @@ -1158,7 +1158,7 @@ Everything below is exported from `@opencode/ai/effect` unless noted.

- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
- **`LLM.generate` / `LLM.stream`** — run direct input or an `LLMRequest` through `LLMClient` for one-import use.
- **`LLM.loop`** — stream model calls, running `Tool.make(...)` tools between them until the model stops calling tools.
- **`LLM.loop`** — stream model calls, running `tool(...)` tools between them until the model stops calling tools.
- **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model.
- **`LanguageModel.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
Expand Down
7 changes: 4 additions & 3 deletions packages/ai/example/tutorial.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Config, Effect, Formatter, Schema, Stream } from "effect"
import { HttpClientResponse } from "effect/http"
import { NodeFileSystem } from "@effect/platform-node"
import { AIClient, Image, LLM, LLMRequest, Media, Message, ProviderID, Tool, ToolRuntime } from "@opencode/ai/effect"
import { AIClient, Image, LLM, LLMRequest, Media, Message, ProviderID, ToolRuntime } from "@opencode/ai/effect"
import { toDefinitions, tool } from "@opencode/ai/effect"
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode/ai/route"
import { OpenAI } from "@opencode/ai/providers"

Expand Down Expand Up @@ -86,7 +87,7 @@ const streamText = LLM.stream(request).pipe(
// advertise definitions on the request, stream one turn, dispatch local calls,
// then persist/build follow-up history in the enclosing product flow.
const tools = {
get_weather: Tool.make({
get_weather: tool({
description: "Get current weather for a city.",
parameters: Schema.Struct({ city: Schema.String }),
success: Schema.Struct({ forecast: Schema.String }),
Expand All @@ -99,7 +100,7 @@ const streamWithTools = Effect.gen(function* () {
model,
prompt: "Use get_weather for San Francisco, then answer in one sentence.",
generation: { maxTokens: 80, temperature: 0 },
tools: Tool.toDefinitions(tools),
tools: toDefinitions(tools),
})
const events = Array.from(yield* LLM.stream(request).pipe(Stream.runCollect))
for (const event of events) {
Expand Down
8 changes: 3 additions & 5 deletions packages/ai/src/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,18 +221,16 @@ export type {
Snapshot as GenerationSnapshot,
Status as GenerationStatus,
} from "./generation.js"
export { Tool, ToolFailure, emptyInputJsonSchema, toDefinitions } from "./tool.js"
export { ToolFailure, emptyInputJsonSchema, toDefinitions, tool } from "./tool.js"
export { ToolRuntime } from "./tool-runtime.js"
export type { DispatchResult as ToolDispatchResult, ToolSettlement } from "./tool-runtime.js"
export type {
AnyExecutableTool,
AnyTool,
ExecutableTool,
ExecutableTools,
Definition as ToolShape,
Tool,
ToolExecute,
ToolExecuteContext,
ToolModelOutputInput,
ToolOptions,
Tools,
ToolSchema,
ToolToModelOutput,
Expand Down
50 changes: 25 additions & 25 deletions packages/ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
type TranscriptionRequest,
type TranscriptionRequestInput,
} from "./transcription.js"
import { Tool, ToolFailure, type FreeformOption, type ToolExecuteContext, type ToolSchema } from "./tool.js"
import { ToolFailure, build, type ToolExecuteContext, type ToolOptions, type ToolSchema } from "./tool.js"
import { fileMediaType } from "./utils/media-type.js"
import { Video, type VideoModel, type VideoRequest, type VideoRequestInput } from "./video.js"

Expand Down Expand Up @@ -278,32 +278,32 @@ export type Client<R = never> = ReturnType<typeof make<R>>
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 freeform?: FreeformOption<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,
freeform: config.freeform,
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 }),
}),
})
/** `tool` from `@opencode/ai/effect`, with an `execute` that returns a value or a Promise and throws to fail. */
export const tool = <
Parameters extends ToolSchema<any> = typeof Schema.Unknown,
Success extends ToolSchema<any> = typeof Schema.Unknown,
Result extends Schema.Schema.Type<Success> | PromiseLike<Schema.Schema.Type<Success>> = never,
>(
config: ToolOptions<
Parameters,
Success,
(params: Schema.Schema.Type<Parameters>, context: Partial<ToolExecuteContext> & { signal: AbortSignal }) => Result
>,
) => {
const execute = config.execute
return build(
config,
execute &&
((params, context) =>
Effect.tryPromise({
try: async (signal) => execute(params, { ...context, signal }),
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()

export * as AI from "./index.js"
export type { ExecutableTool, Tool, ToolOptions, Tools } from "./tool.js"
28 changes: 5 additions & 23 deletions packages/ai/src/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
type LanguageModelProviderOptions,
type ToolEntryInput,
} from "./schema/index.js"
import { make as makeTool, toDefinitions, type ExecutableTools, type ToolSchema } from "./tool.js"
import { tool, toDefinitions, type ExecutableTool, type Tool, type ToolSchema } from "./tool.js"
import { ToolRuntime } from "./tool-runtime.js"

/** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */
Expand Down Expand Up @@ -57,7 +57,7 @@ export type LoopInput<SelectedLanguageModel extends LanguageModel = LanguageMode
RequestInput<SelectedLanguageModel>,
"tools"
> & {
readonly tools: ExecutableTools
readonly tools: Record<string, ExecutableTool>
/** Called after each step that has tool calls; returning `true` ends the loop without running them. */
readonly until?: (state: { readonly steps: ReadonlyArray<LLMResponse> }) => boolean
}
Expand Down Expand Up @@ -185,10 +185,7 @@ export interface GenerateObjectDynamicOptions<SelectedLanguageModel extends Lang
readonly jsonSchema: JsonSchema.JsonSchema
}

const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
options: GenerateObjectBase,
tool: ReturnType<typeof makeTool>,
) {
const runGenerateObject = Effect.fn("LLM.generateObject")(function* (options: GenerateObjectBase, tool: Tool) {
const baseRequest = request(options)
const generateRequest = LLMRequest.update(baseRequest, {
tools: toDefinitions({ [GENERATE_OBJECT_TOOL_NAME]: tool }),
Expand Down Expand Up @@ -237,23 +234,8 @@ export function generateObject<const SelectedLanguageModel extends LanguageModel
export function generateObject(options: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions) {
if ("schema" in options) {
const { schema, ...rest } = options
return runGenerateObject(
rest,
makeTool({
description: GENERATE_OBJECT_TOOL_DESCRIPTION,
parameters: schema,
success: Schema.Unknown as ToolSchema<unknown>,
execute: () => Effect.void,
}),
)
return runGenerateObject(rest, tool({ description: GENERATE_OBJECT_TOOL_DESCRIPTION, parameters: schema }))
}
const { jsonSchema, ...rest } = options
return runGenerateObject(
rest,
makeTool({
description: GENERATE_OBJECT_TOOL_DESCRIPTION,
jsonSchema,
execute: () => Effect.void,
}),
)
return runGenerateObject(rest, tool({ description: GENERATE_OBJECT_TOOL_DESCRIPTION, jsonSchema }))
}
2 changes: 1 addition & 1 deletion packages/ai/src/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export const textWithUsage = (value: string, id: string, inputTokens: number) =>
...textEvents(value, id),
)

export const tool = (id: string, name: string, input: unknown) => toolCalls(LLMEvent.toolCall({ id, name, input }))
export const toolCall = (id: string, name: string, input: unknown) => toolCalls(LLMEvent.toolCall({ id, name, input }))

export const failAfter = (error: AIError, ...events: readonly LLMEvent[]) =>
Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(error)))
Expand Down
4 changes: 2 additions & 2 deletions packages/ai/src/tool-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
type ToolOutput as ToolOutputType,
type ToolResultValue as ToolResultValueType,
} from "./schema/index.js"
import { type AnyTool, type Tools } from "./tool.js"
import { type Tool, type Tools } from "./tool.js"

export interface ToolSettlement {
readonly result: ToolResultValueType
Expand Down Expand Up @@ -41,7 +41,7 @@ export const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<Dispat
)
}

const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect<ToolSettlement, ToolFailure> =>
const decodeAndExecute = (tool: Tool, call: ToolCallPart): Effect.Effect<ToolSettlement, ToolFailure> =>
tool._decode(call.input).pipe(
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
Effect.flatMap((decoded) =>
Expand Down
Loading
Loading