refactor(tools): unify tool APIs and result handling (#38367)

This commit is contained in:
Kit Langton 2026-07-23 17:13:31 -04:00 committed by GitHub
commit 79c1544072
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
133 changed files with 3602 additions and 2770 deletions

View file

@ -0,0 +1,315 @@
import { Agent } from "@opencode-ai/schema/agent"
import { LLM } from "@opencode-ai/schema/llm"
import { Session } from "@opencode-ai/schema/session"
import { SessionError } from "@opencode-ai/schema/session-error"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
import { Effect, JsonSchema, Schema } from "effect"
import type { Hooks, Transform } from "../registration.js"
// Tools
/** A JSON-compatible value. Tool metadata and encoded outputs must be JSON. */
export type JsonValue = typeof Schema.Json.Type
/** Compact JSON metadata for tool-specific UI and client behavior. */
export type Metadata = Readonly<Record<string, JsonValue>>
export interface Context {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: string
readonly progress: (update: Progress) => Effect.Effect<void>
}
/** Live replacement metadata for a running tool. */
export type Progress = Metadata
export type StandardSchemaType<Input = unknown, Output = Input> = StandardSchemaV1<Input, Output> &
StandardJSONSchemaV1<Input, Output>
export type SchemaType<A = unknown> = Schema.Codec<A, any> | StandardSchemaType<any, A> | JsonSchema.JsonSchema
type IsAny<A> = 0 extends 1 & A ? true : false
export type InputValue<S> =
IsAny<S> extends true
? any
: S extends Schema.Codec<infer A, any>
? A
: S extends StandardSchemaV1<any, infer A>
? A
: unknown
export type OutputValue<S> =
IsAny<S> extends true
? any
: S extends Schema.Codec<infer A, any>
? A
: S extends StandardSchemaV1<infer A, any>
? A
: unknown
export type EncodedValue<S> =
IsAny<S> extends true
? any
: S extends Schema.Codec<any, infer A>
? A
: S extends StandardSchemaV1<any, infer A>
? A
: unknown
type ToolDefinition = {
readonly name: string
readonly description: string
readonly inputSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
}
export class Failure extends Schema.TaggedErrorClass<Failure>()("LLM.ToolFailure", {
message: Schema.String,
error: Schema.optional(Schema.Defect()),
}) {}
export class RegistrationError extends Schema.TaggedErrorClass<RegistrationError>()("Tool.RegistrationError", {
name: Schema.String,
message: Schema.String,
}) {}
export type Content =
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
/** Model-facing tool content: plain text or non-empty rich content. */
export type ModelOutput = string | readonly [Content, ...Content[]]
type BaseTool<Input extends SchemaType<any>> = {
readonly description: string
readonly input: Input
}
export type Response<Output extends SchemaType<any>> = {
readonly output: OutputValue<Output>
readonly content?: ModelOutput
readonly metadata?: Metadata
}
export type ContentResponse = {
readonly content: ModelOutput
readonly metadata?: Metadata
}
export type Tool<
Input extends SchemaType<any>,
Output extends SchemaType<any> | undefined = undefined,
> = BaseTool<Input> &
(Output extends SchemaType<any>
? {
readonly output: Output
readonly execute: (input: InputValue<Input>, context: Context) => Effect.Effect<Response<Output>, Failure>
}
: {
readonly output?: undefined
readonly execute: (input: InputValue<Input>, context: Context) => Effect.Effect<ContentResponse, Failure>
})
export type Any = BaseTool<any> & {
readonly output?: SchemaType<any>
readonly execute: (input: any, context: Context) => Effect.Effect<Response<any> | ContentResponse, Failure>
}
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
config: Tool<Input, Output>,
): Tool<Input, Output>
export function make<Input extends SchemaType<any>>(config: Tool<Input>): Tool<Input>
export function make(config: Any): Any
export function make(config: Any): Any {
return config
}
// Registration
export interface RegisterOptions {
readonly namespace?: string
/** Defaults to true. False exposes the tool directly to the provider. */
readonly codemode?: boolean
/** Permission action used for whole-tool visibility filtering. */
readonly permission?: string
}
export interface Registration {
readonly tool: Any
readonly name: string
readonly namespace?: string
readonly permission: string
}
export const validateName = (name: string) =>
/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name)
? Effect.void
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
export const registrationEntries = (
tools: Readonly<Record<string, Any>>,
options?: RegisterOptions,
): Array<Registration & { readonly key: string }> =>
Object.entries(tools).map(([name, tool]) => {
const normalized = name.replace(/[^a-zA-Z0-9_-]/g, "_")
const key =
options?.namespace === undefined ? normalized : `${options.namespace.replaceAll(".", "_")}_${normalized}`
return {
key,
name: normalized,
namespace: options?.namespace,
tool,
permission: options?.permission ?? key,
}
})
export const validateNamespace = (namespace: string) =>
namespace.split(".").every((segment) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(segment))
? Effect.void
: Effect.fail(
new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` }),
)
export const toLLMDefinition = (name: string, tool: Any): ToolDefinition => ({
name,
description: tool.description,
inputSchema: inputJsonSchema(tool.input),
...(tool.output === undefined ? {} : { outputSchema: outputJsonSchema(tool.output) }),
})
// Schema interpretation
export function decodeInput(schema: SchemaType<any>, value: unknown): Effect.Effect<any, Failure> {
if (Schema.isSchema(schema))
return Schema.decodeUnknownEffect(schema)(value).pipe(
Effect.mapError((error) => new Failure({ message: `Invalid tool input: ${error.message}` })),
)
if (isStandardSchema(schema)) return validateStandard(schema, value, "Invalid tool input")
return Effect.succeed(value)
}
export function encodeOutput(schema: SchemaType<any>, value: unknown): Effect.Effect<any, Failure> {
if (Schema.isSchema(schema))
return Schema.encodeEffect(schema)(value).pipe(
Effect.mapError(
(error) => new Failure({ message: `Tool returned an invalid value for its output schema: ${error.message}` }),
),
)
if (isStandardSchema(schema))
return validateStandard(schema, value, "Tool returned an invalid value for its output schema")
return Schema.decodeUnknownEffect(Schema.Json)(value).pipe(
Effect.mapError(
(error) => new Failure({ message: `Tool returned a non-JSON value for its output schema: ${error.message}` }),
),
)
}
function isStandardSchema(schema: SchemaType<any>): schema is StandardSchemaType {
return "~standard" in schema
}
function validateStandard(schema: StandardSchemaType, value: unknown, prefix: string): Effect.Effect<unknown, Failure> {
return Effect.gen(function* () {
const pending = yield* Effect.try({
try: () => schema["~standard"].validate(value),
catch: (error) => standardFailure(prefix, error),
})
const result =
pending instanceof Promise
? yield* Effect.tryPromise({ try: () => pending, catch: (error) => standardFailure(prefix, error) })
: pending
if (result.issues)
return yield* Effect.fail(
new Failure({ message: `${prefix}: ${result.issues.map((issue) => issue.message).join(", ")}` }),
)
return result.value
})
}
function standardFailure(prefix: string, error: unknown) {
return new Failure({ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}` })
}
function inputJsonSchema(schema: SchemaType<any>): JsonSchema.JsonSchema {
if (isStandardSchema(schema))
return schema["~standard"].jsonSchema.input({ target: "draft-2020-12" }) as JsonSchema.JsonSchema
return Schema.isSchema(schema) ? toJsonSchema(schema) : (schema as JsonSchema.JsonSchema)
}
function outputJsonSchema(schema: SchemaType<any>): JsonSchema.JsonSchema {
if (isStandardSchema(schema))
return schema["~standard"].jsonSchema.output({ target: "draft-2020-12" }) as JsonSchema.JsonSchema
return Schema.isSchema(schema) ? toJsonSchema(schema) : (schema as JsonSchema.JsonSchema)
}
function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema {
const document = Schema.toJsonSchemaDocument(schema)
if (Object.keys(document.definitions).length === 0) return document.schema
return { ...document.schema, $defs: document.definitions }
}
// Plugin events
export interface ToolExecuteBeforeEvent {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: string
input: unknown
}
type ToolHookBase = {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: string
readonly input: unknown
}
export const ExecuteAfterOutcome = Schema.Union([
Schema.Struct({
status: Schema.Literal("completed"),
content: Schema.NonEmptyArray(LLM.ToolContent),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Json)),
outputPaths: Schema.optional(Schema.Array(Schema.String)),
}),
Schema.Struct({
status: Schema.Literal("error"),
error: SessionError.Error,
content: Schema.optional(Schema.NonEmptyArray(LLM.ToolContent)),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Json)),
outputPaths: Schema.optional(Schema.Array(Schema.String)),
}),
]).pipe(Schema.toTaggedUnion("status"))
type Mutable<A> = { -readonly [K in keyof A]: A[K] }
type HookOutcome<A extends { readonly status: string }> = Omit<Mutable<A>, "status"> & Pick<A, "status">
/** The bounded terminal outcome exposed to tool hooks. */
export type Outcome = typeof ExecuteAfterOutcome.Type extends infer A
? A extends { readonly status: string }
? HookOutcome<A>
: never
: never
/**
* The canonical execution outcome as seen by `execute.after` hooks. Hooks
* observe bounded model content, optional metadata, and managed output paths;
* they never observe the raw domain output.
*/
export type ToolExecuteAfterEvent = ToolHookBase & Outcome
export interface ToolDraft {
add(name: string, tool: Any, options?: RegisterOptions): void
}
export interface ToolHooks {
readonly "execute.before": ToolExecuteBeforeEvent
readonly "execute.after": ToolExecuteAfterEvent
}
export interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly hook: Hooks<ToolHooks>
}

View file

@ -1,314 +1,2 @@
export * as Tool from "./tool.js"
import { Agent } from "@opencode-ai/schema/agent"
import type { LLM } from "@opencode-ai/schema/llm"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
import { Effect, JsonSchema, Schema } from "effect"
import type { Hooks, Transform } from "./registration.js"
export interface Context {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: string
readonly progress: (update: Progress) => Effect.Effect<void>
}
export interface Progress {
readonly structured: Readonly<Record<string, unknown>>
readonly content?: ReadonlyArray<Content>
}
export type StandardSchemaType<Input = unknown, Output = Input> = StandardSchemaV1<Input, Output> &
StandardJSONSchemaV1<Input, Output>
export type SchemaType<A> = Schema.Codec<A, any> | StandardSchemaType<any, A>
type IsAny<A> = 0 extends 1 & A ? true : false
export type InputValue<S> =
IsAny<S> extends true
? any
: S extends Schema.Codec<infer A, any>
? A
: S extends StandardSchemaV1<any, infer A>
? A
: never
export type OutputValue<S> =
IsAny<S> extends true
? any
: S extends Schema.Codec<infer A, any>
? A
: S extends StandardSchemaV1<infer A, any>
? A
: never
export type EncodedValue<S> =
IsAny<S> extends true
? any
: S extends Schema.Codec<any, infer A>
? A
: S extends StandardSchemaV1<any, infer A>
? A
: never
type ToolDefinition = {
readonly name: string
readonly description: string
readonly inputSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
}
type ToolCall = {
readonly input: unknown
readonly [key: string]: unknown
}
type ToolResultValue =
| { readonly type: "json"; readonly value: unknown }
| { readonly type: "text"; readonly value: unknown }
| { readonly type: "error"; readonly value: unknown }
| { readonly type: "content"; readonly value: ReadonlyArray<LLM.ToolContent> }
type ToolOutput = {
readonly structured: unknown
readonly content: ReadonlyArray<LLM.ToolContent>
}
export class Failure extends Schema.TaggedErrorClass<Failure>()("LLM.ToolFailure", {
message: Schema.String,
error: Schema.optional(Schema.Defect()),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
export class RegistrationError extends Schema.TaggedErrorClass<RegistrationError>()("Tool.RegistrationError", {
name: Schema.String,
message: Schema.String,
}) {}
export type Content =
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
export type Definition<
Input extends SchemaType<any>,
Structured extends SchemaType<any>,
Output extends SchemaType<any> = any,
> = {
readonly description: string
readonly input: Input
readonly output: Output
readonly structured?: Structured
readonly permission?: string
readonly toStructuredOutput?: (input: {
readonly input: InputValue<Input>
readonly output: EncodedValue<Output>
}) => OutputValue<Structured>
readonly execute: (input: InputValue<Input>, context: Context) => Effect.Effect<OutputValue<Output>, Failure>
readonly toModelOutput?: (input: {
readonly input: InputValue<Input>
readonly output: EncodedValue<Output>
}) => ReadonlyArray<Content>
}
export type DynamicOutput = {
readonly structured: unknown
readonly content: ReadonlyArray<Content>
}
/**
* Config for a tool whose input shape is a raw JSON Schema not known at compile
* time (MCP servers, plugin manifests). Input is passed through as `unknown`;
* `execute` returns the already-projected structured value and model content.
*/
export type DynamicDefinition = {
readonly description: string
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
readonly permission?: string
readonly execute: (input: unknown, context: Context) => Effect.Effect<DynamicOutput, Failure>
}
export type AnyTool = Definition<any, any> | DynamicDefinition
export function make<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
>(config: Definition<Input, Structured, Output>): Definition<Input, Structured, Output>
export function make(config: DynamicDefinition): DynamicDefinition
export function make(config: AnyTool): AnyTool
export function make(config: AnyTool): AnyTool {
return config
}
function toModelContent(part: Content) {
if (part.type === "text") return { type: "text" as const, text: part.text }
return { type: "file" as const, uri: `data:${part.mime};base64,${part.data}`, mime: part.mime, name: part.name }
}
export const validateName = (name: string) =>
/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name)
? Effect.void
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
export const registrationEntries = (tools: Readonly<Record<string, AnyTool>>, namespace?: string) =>
Object.entries(tools).map(([name, tool]) => {
const normalized = name.replace(/[^a-zA-Z0-9_-]/g, "_")
return {
key: namespace === undefined ? normalized : `${namespace.replaceAll(".", "_")}_${normalized}`,
name: normalized,
namespace,
tool,
}
})
export const validateNamespace = (namespace: string) =>
namespace.split(".").every((segment) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(segment))
? Effect.void
: Effect.fail(
new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` }),
)
export const withPermission = <T extends AnyTool>(
tool: T,
permission: string,
): Omit<T, "permission"> & {
readonly permission: string
} => ({ ...tool, permission })
export const permission = (tool: AnyTool, name: string) => tool.permission ?? name
export const definition = (name: string, tool: AnyTool): ToolDefinition =>
"jsonSchema" in tool
? {
name,
description: tool.description,
inputSchema: tool.jsonSchema,
outputSchema: tool.outputSchema,
}
: {
name,
description: tool.description,
inputSchema: inputJsonSchema(tool.input),
outputSchema: outputJsonSchema(tool.structured ?? tool.output),
}
export const settle = (tool: AnyTool, call: ToolCall, context: Context): Effect.Effect<ToolOutput, Failure> =>
Effect.gen(function* () {
if ("jsonSchema" in tool) {
const output = yield* tool.execute(call.input, context)
return { structured: output.structured, content: output.content.map(toModelContent) }
}
const input = yield* decodeInput(tool.input, call.input)
const value = yield* tool.execute(input, context)
const output = yield* encodeOutput(tool.output, value)
const structured =
tool.structured && tool.toStructuredOutput
? yield* encodeOutput(tool.structured, tool.toStructuredOutput({ input, output }))
: output
return {
structured,
content:
tool.toModelOutput?.({ input, output }).map(toModelContent) ??
(typeof output === "string" ? [{ type: "text" as const, text: output }] : []),
}
})
function decodeInput(schema: SchemaType<any>, value: unknown): Effect.Effect<any, Failure> {
if (Schema.isSchema(schema))
return Schema.decodeUnknownEffect(schema)(value).pipe(
Effect.mapError((error) => new Failure({ message: `Invalid tool input: ${error.message}` })),
)
return validateStandard(schema, value, "Invalid tool input")
}
function encodeOutput(schema: SchemaType<any>, value: unknown): Effect.Effect<any, Failure> {
if (Schema.isSchema(schema))
return Schema.encodeEffect(schema)(value).pipe(
Effect.mapError(
(error) => new Failure({ message: `Tool returned an invalid value for its output schema: ${error.message}` }),
),
)
return validateStandard(schema, value, "Tool returned an invalid value for its output schema")
}
function validateStandard(schema: StandardSchemaType, value: unknown, prefix: string): Effect.Effect<unknown, Failure> {
return Effect.gen(function* () {
const pending = yield* Effect.try({
try: () => schema["~standard"].validate(value),
catch: (error) => standardFailure(prefix, error),
})
const result =
pending instanceof Promise
? yield* Effect.tryPromise({ try: () => pending, catch: (error) => standardFailure(prefix, error) })
: pending
if (result.issues)
return yield* Effect.fail(
new Failure({ message: `${prefix}: ${result.issues.map((issue) => issue.message).join(", ")}` }),
)
return result.value
})
}
function standardFailure(prefix: string, error: unknown) {
return new Failure({ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}` })
}
function inputJsonSchema(schema: SchemaType<any>): JsonSchema.JsonSchema {
if (!Schema.isSchema(schema))
return schema["~standard"].jsonSchema.input({ target: "draft-2020-12" }) as JsonSchema.JsonSchema
return toJsonSchema(schema)
}
function outputJsonSchema(schema: SchemaType<any>): JsonSchema.JsonSchema {
if (!Schema.isSchema(schema))
return schema["~standard"].jsonSchema.output({ target: "draft-2020-12" }) as JsonSchema.JsonSchema
return toJsonSchema(schema)
}
function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema {
const document = Schema.toJsonSchemaDocument(schema)
if (Object.keys(document.definitions).length === 0) return document.schema
return { ...document.schema, $defs: document.definitions }
}
export interface ToolExecuteBeforeEvent {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: string
input: unknown
}
export interface ToolExecuteAfterEvent {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: string
readonly input: unknown
result: ToolResultValue
output?: ToolOutput
outputPaths?: ReadonlyArray<string>
}
export interface RegisterOptions {
readonly namespace?: string
/** Defaults to true. False exposes the tool directly to the provider. */
readonly codemode?: boolean
}
export interface ToolDraft {
add(name: string, tool: AnyTool, options?: RegisterOptions): void
}
export interface ToolHooks {
readonly "execute.before": ToolExecuteBeforeEvent
readonly "execute.after": ToolExecuteAfterEvent
}
export interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly hook: Hooks<ToolHooks>
}
export * as Tool from "./internal/tool.js"
export * from "./internal/tool.js"

View file

@ -94,19 +94,23 @@ await ctx.session.hook("context", (event) => {
})
```
Promise tools use plain object declarations with async executors:
Promise tools use executable tool values with async executors. Registration
supplies the tool's name and options separately:
```ts
import { Schema } from "effect"
import { Tool } from "@opencode-ai/plugin/v2/tool"
await ctx.tool.transform((tools) => {
tools.add({
name: "echo",
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: async ({ text }) => ({ text }),
})
tools.add(
"echo",
Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: async ({ text }) => ({ output: { text }, content: text }),
}),
)
})
```

View file

@ -0,0 +1,64 @@
import type { Hooks, Transform } from "../registration.js"
export type Context = Omit<import("../../effect/internal/tool.js").Context, "progress"> & {
readonly progress: (update: import("../../effect/internal/tool.js").Progress) => Promise<void>
}
export type SchemaType<A> = import("../../effect/internal/tool.js").SchemaType<A>
export type Content = import("../../effect/internal/tool.js").Content
export type Metadata = import("../../effect/internal/tool.js").Metadata
export type ModelOutput = import("../../effect/internal/tool.js").ModelOutput
export type Tool<Input extends SchemaType<any>, Output extends SchemaType<any> | undefined = undefined> = Omit<
import("../../effect/internal/tool.js").Tool<Input, Output>,
"execute"
> & {
readonly execute: (
input: import("../../effect/internal/tool.js").InputValue<Input>,
context: Context,
) => Promise<
Output extends SchemaType<any>
? import("../../effect/internal/tool.js").Response<Output>
: import("../../effect/internal/tool.js").ContentResponse
>
}
export type Any = Omit<import("../../effect/internal/tool.js").Any, "execute"> & {
readonly execute: (
input: any,
context: Context,
) => Promise<
import("../../effect/internal/tool.js").Response<any> | import("../../effect/internal/tool.js").ContentResponse
>
}
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
tool: Tool<Input, Output>,
): Tool<Input, Output>
export function make<Input extends SchemaType<any>>(tool: Tool<Input>): Tool<Input>
export function make(tool: Any): Any
export function make(tool: Any): Any {
return tool
}
export type ToolExecuteBeforeEvent = import("../../effect/internal/tool.js").ToolExecuteBeforeEvent
export type ToolExecuteAfterEvent = import("../../effect/internal/tool.js").ToolExecuteAfterEvent
export type RegisterOptions = import("../../effect/internal/tool.js").RegisterOptions
export interface ToolDraft {
add<Input extends SchemaType<any>, Output extends SchemaType<any>>(
name: string,
tool: Tool<Input, Output>,
options?: RegisterOptions,
): void
add<Input extends SchemaType<any>>(name: string, tool: Tool<Input>, options?: RegisterOptions): void
}
export interface ToolHooks {
readonly "execute.before": ToolExecuteBeforeEvent
readonly "execute.after": ToolExecuteAfterEvent
}
export interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly hook: Hooks<ToolHooks>
}

View file

@ -1,48 +1,2 @@
import type { Tool } from "../effect/tool.js"
import type { Hooks, Transform } from "./registration.js"
export type Context = Omit<Tool.Context, "progress"> & {
readonly progress: (update: Tool.Progress) => Promise<void>
}
export type SchemaType<A> = Tool.SchemaType<A>
export type Content = Tool.Content
export type DynamicOutput = Tool.DynamicOutput
export type Definition<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
> = Omit<Tool.Definition<Input, Structured, Output>, "execute" | "permission"> & {
readonly name: string
readonly options?: RegisterOptions
readonly execute: (input: Tool.InputValue<Input>, context: Context) => Promise<Tool.OutputValue<Output>>
}
export type DynamicDefinition = Omit<Tool.DynamicDefinition, "execute" | "permission"> & {
readonly name: string
readonly options?: RegisterOptions
readonly execute: (input: unknown, context: Context) => Promise<DynamicOutput>
}
export type AnyTool = Definition<any, any, any> | DynamicDefinition
export type ToolExecuteBeforeEvent = Tool.ToolExecuteBeforeEvent
export type ToolExecuteAfterEvent = Tool.ToolExecuteAfterEvent
export type RegisterOptions = Tool.RegisterOptions
export interface ToolDraft {
add<Input extends SchemaType<any>, Output extends SchemaType<any>, Structured extends SchemaType<any> = Output>(
tool: Definition<Input, Output, Structured>,
): void
add(tool: DynamicDefinition): void
}
export interface ToolHooks {
readonly "execute.before": ToolExecuteBeforeEvent
readonly "execute.after": ToolExecuteAfterEvent
}
export interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly hook: Hooks<ToolHooks>
}
export * as Tool from "./internal/tool.js"
export * from "./internal/tool.js"

View file

@ -1,29 +1,18 @@
import { expect, test } from "bun:test"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Effect, Schema } from "effect"
import * as Tool from "../src/v2/effect/tool"
const context = {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_test"),
callID: "call_test",
progress: () => Effect.void,
} satisfies Tool.Context
test("tools remain valid across separate module instances", async () => {
const ForeignTool = await import(`${new URL("../src/v2/effect/tool.ts", import.meta.url).href}?foreign`)
const config = {
description: "Foreign tool",
input: Schema.Struct({ value: Schema.String }),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
execute: () => Effect.succeed({ output: { ok: true } }),
}
const tool = ForeignTool.make(config)
expect(Tool.definition("foreign", tool)).toEqual({
expect(Tool.toLLMDefinition("foreign", tool)).toEqual({
name: "foreign",
description: "Foreign tool",
inputSchema: {
@ -39,10 +28,7 @@ test("tools remain valid across separate module instances", async () => {
additionalProperties: false,
},
})
expect(await Effect.runPromise(Tool.settle(tool, { input: { value: "input" } }, context))).toEqual({
structured: { ok: true },
content: [],
})
expect(await Effect.runPromise(Tool.decodeInput(tool.input, { value: "input" }))).toEqual({ value: "input" })
})
test("portable schemas validate and describe typed tools", async () => {
@ -77,19 +63,18 @@ test("portable schemas validate and describe typed tools", async () => {
description: "Portable tool",
input,
output,
execute: ({ count }) => Effect.succeed(count + 1),
execute: ({ count }) => Effect.succeed({ output: count + 1 }),
})
expect(Tool.definition("portable", tool)).toEqual({
expect(Tool.toLLMDefinition("portable", tool)).toEqual({
name: "portable",
description: "Portable tool",
inputSchema: { type: "object", properties: { count: { type: "string" } } },
outputSchema: { type: "string" },
})
expect(await Effect.runPromise(Tool.settle(tool, { input: { count: "41" } }, context))).toEqual({
structured: "42",
content: [{ type: "text", text: "42" }],
})
const decoded = await Effect.runPromise(Tool.decodeInput(tool.input, { count: "41" }))
expect(decoded).toEqual({ count: 41 })
expect(await Effect.runPromise(Tool.encodeOutput(tool.output, 42))).toBe("42")
})
test("portable schema failures become tool failures", async () => {
@ -104,29 +89,39 @@ test("portable schema failures become tool failures", async () => {
},
},
}
const tool = Tool.make({
description: "Failing tool",
input,
output: input,
execute: Effect.succeed,
})
const error = await Effect.runPromiseExit(Tool.settle(tool, { input: 1 }, context))
const error = await Effect.runPromiseExit(Tool.decodeInput(input, 1))
expect(error.toString()).toContain("Invalid tool input: expected a string")
})
test("two-parameter Definition annotations retain their original meaning", () => {
test("canonical results carry metadata with typed output", async () => {
const input = Schema.Struct({ value: Schema.String })
const output = Schema.Struct({ value: Schema.String, internal: Schema.Boolean })
const structured = Schema.Struct({ value: Schema.String })
const tool: Tool.Definition<typeof input, typeof structured> = Tool.make({
const tool = Tool.make({
description: "Annotated tool",
input,
output,
structured,
toStructuredOutput: ({ output }) => ({ value: output.value }),
execute: ({ value }) => Effect.succeed({ value, internal: true }),
execute: ({ value }) => Effect.succeed({ output: { value, internal: true }, metadata: { value }, content: value }),
})
expect(tool.structured).toBe(structured)
expect(await Effect.runPromise(tool.execute({ value: "out" }, {} as Tool.Context))).toEqual({
output: { value: "out", internal: true },
metadata: { value: "out" },
content: "out",
})
})
test("raw JSON schemas are render-only and omitted output means model-only", async () => {
const tool = Tool.make({
description: "Raw tool",
input: { type: "object", properties: { value: { type: "string" } } },
execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
})
expect(Tool.toLLMDefinition("raw", tool)).toEqual({
name: "raw",
description: "Raw tool",
inputSchema: { type: "object", properties: { value: { type: "string" } } },
})
expect(await Effect.runPromise(Tool.decodeInput(tool.input, { value: 1 }))).toEqual({ value: 1 })
})