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
71 changes: 6 additions & 65 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/core/src/tool/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ This folder owns Core's one local tool representation, process and Location regi

## Representations

- `tool.ts` defines the opaque canonical `Tool.make({ description, input, output, execute, toModelOutput })` value. Shipped built-ins and plugin tools use the same type.
- `tool.ts` defines the structural canonical `Tool.make({ description, input, output, execute, toModelOutput })` declaration. Shipped built-ins and plugin tools use the same type.
- `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers.
- `registry.ts` stores only canonical Location registrations, derives definitions, invokes tools, and applies generic output bounding.

Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.

## Construction

Tool schemas and projection use `input` and `output` terminology. A tool value is opaque: its codecs, executor, definition derivation, and catalog permission declaration are private runtime details.
Tool schemas and projection use `input` and `output` terminology. A tool value carries its schemas, executor, projection, and optional catalog permission directly so separately loaded plugin package instances can exchange it structurally.

Location-scoped built-in layers acquire `PermissionV2.Service` and every other required Location service while the layer is constructed. The executor captures those services. Permission sources are always constructed from the canonical invocation context:

Expand Down
11 changes: 0 additions & 11 deletions packages/core/test/session-runner-tool-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,17 +107,6 @@ describe("ToolRegistry", () => {
}),
)

it.effect("reuses model definitions across requests", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() }, { codemode: false })
const first = yield* toolDefinitions(service)
const second = yield* toolDefinitions(service)

expect(second[0]).toBe(first[0])
}),
)

it.effect("removes a scoped registration", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
Expand Down
1 change: 1 addition & 0 deletions packages/plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@standard-schema/spec": "^1.1.0",
"effect": "catalog:",
"zod": "catalog:"
},
Expand Down
269 changes: 140 additions & 129 deletions packages/plugin/src/v2/effect/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ 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 { Effect, JsonSchema, Schema, type Scope } from "effect"
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
import { Effect, JsonSchema, Schema } from "effect"
import type { Hooks, Transform } from "./registration.js"

export interface Context {
Expand All @@ -20,7 +21,34 @@ export interface Progress {
readonly content?: ReadonlyArray<Content>
}

export type SchemaType<A> = Schema.Codec<A, any>
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
Expand All @@ -45,16 +73,6 @@ type ToolOutput = {
readonly content: ReadonlyArray<LLM.ToolContent>
}

declare const TypeId: unique symbol

export interface Definition<Input extends SchemaType<any>, Output extends SchemaType<any>> {
readonly [TypeId]: {
readonly _Input: Input
readonly _Output: Output
}
}

export type AnyTool = Definition<any, any>
export class Failure extends Schema.TaggedErrorClass<Failure>()("LLM.ToolFailure", {
message: Schema.String,
error: Schema.optional(Schema.Defect()),
Expand All @@ -70,26 +88,24 @@ export type Content =
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }

type Config<
export type Definition<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
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: Schema.Schema.Type<Input>
readonly output: Output["Encoded"]
}) => Schema.Schema.Type<Structured>
readonly execute: (
input: Schema.Schema.Type<Input>,
context: Context,
) => Effect.Effect<Schema.Schema.Type<Output>, Failure>
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: Schema.Schema.Type<Input>
readonly output: Output["Encoded"]
readonly input: InputValue<Input>
readonly output: EncodedValue<Output>
}) => ReadonlyArray<Content>
}

Expand All @@ -103,109 +119,25 @@ export type DynamicOutput = {
* time (MCP servers, plugin manifests). Input is passed through as `unknown`;
* `execute` returns the already-projected structured value and model content.
*/
type DynamicConfig = {
export type DynamicDefinition = {
readonly description: string
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
readonly execute: (input: unknown, context: Context) => Effect.Effect<DynamicOutput, Failure>
}

type Runtime = {
readonly permission?: string
readonly definition: (name: string) => ToolDefinition
readonly settle: (call: ToolCall, context: Context) => Effect.Effect<ToolOutput, Failure>
readonly execute: (input: unknown, context: Context) => Effect.Effect<DynamicOutput, Failure>
}

const runtimes = new WeakMap<AnyTool, Runtime>()
export type AnyTool = Definition<any, any> | DynamicDefinition

export function make<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
>(config: Config<Input, Output, Structured>): Definition<Input, Structured>
export function make(config: DynamicConfig): AnyTool
export function make(config: Config<any, any, any> | DynamicConfig): AnyTool {
if ("jsonSchema" in config) return makeDynamic(config)
return makeTyped(config)
}

function makeTyped<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
>(config: Config<Input, Output, Structured>): Definition<Input, Structured> {
const tool = Object.freeze({}) as Definition<Input, Structured>
const definitions = new Map<string, ToolDefinition>()
runtimes.set(tool, {
definition: (name) => {
const cached = definitions.get(name)
if (cached) return cached
const definition: ToolDefinition = {
name,
description: config.description,
inputSchema: toJsonSchema(config.input),
outputSchema: toJsonSchema(config.structured ?? config.output),
}
definitions.set(name, definition)
return definition
},
settle: (call, context) =>
Schema.decodeUnknownEffect(config.input)(call.input).pipe(
Effect.mapError((error) => new Failure({ message: `Invalid tool input: ${error.message}` })),
Effect.flatMap((input) =>
config.execute(input, context).pipe(
Effect.flatMap((output) =>
Schema.encodeEffect(config.output)(output).pipe(
Effect.flatMap((output) => {
if (!config.structured || !config.toStructuredOutput)
return Effect.succeed({ output, structured: output })
return Schema.encodeEffect(config.structured)(config.toStructuredOutput({ input, output })).pipe(
Effect.map((structured) => ({ output, structured })),
)
}),
Effect.mapError(
(error) =>
new Failure({
message: `Tool returned an invalid value for its output schema: ${error.message}`,
}),
),
),
),
Effect.map(({ output, structured }) => ({
structured,
content:
config.toModelOutput?.({ input, output }).map(toModelContent) ??
(typeof output === "string" ? [{ type: "text" as const, text: output }] : []),
})),
),
),
),
})
return tool
}

function makeDynamic(config: DynamicConfig): AnyTool {
const tool = Object.freeze({}) as AnyTool
const definitions = new Map<string, ToolDefinition>()
runtimes.set(tool, {
definition: (name) => {
const cached = definitions.get(name)
if (cached) return cached
const definition: ToolDefinition = {
name,
description: config.description,
inputSchema: config.jsonSchema,
outputSchema: config.outputSchema,
}
definitions.set(name, definition)
return definition
},
settle: (call, context) =>
config
.execute(call.input, context)
.pipe(Effect.map((output) => ({ structured: output.structured, content: output.content.map(toModelContent) }))),
})
return tool
>(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) {
Expand All @@ -230,23 +162,102 @@ export const registrationEntries = (tools: Readonly<Record<string, AnyTool>>, gr
}
})

export const withPermission = <Input extends SchemaType<any>, Output extends SchemaType<any>>(
tool: Definition<Input, Output>,
export const withPermission = <T extends AnyTool>(
tool: T,
permission: string,
) => {
const decorated = Object.freeze({}) as Definition<Input, Output>
runtimes.set(decorated, { ...runtimeOf(tool), permission })
return decorated
): 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")
}

export const permission = (tool: AnyTool, name: string) => runtimeOf(tool).permission ?? name
export const definition = (name: string, tool: AnyTool) => runtimeOf(tool).definition(name)
export const settle = (tool: AnyTool, call: ToolCall, context: Context) => runtimeOf(tool).settle(call, context)
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 runtimeOf(tool: AnyTool) {
const runtime = runtimes.get(tool)
if (!runtime) throw new TypeError("Invalid Tool value")
return runtime
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 {
Expand Down
Loading
Loading