Skip to content

Commit 5844ec6

Browse files
committed
fix(plugin): make tool values structural
1 parent b4be28d commit 5844ec6

8 files changed

Lines changed: 286 additions & 265 deletions

File tree

bun.lock

Lines changed: 6 additions & 65 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/core/src/tool/AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ This folder owns Core's one local tool representation, process and Location regi
44

55
## Representations
66

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

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

1313
## Construction
1414

15-
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.
15+
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.
1616

1717
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:
1818

packages/core/test/session-runner-tool-registry.test.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -107,17 +107,6 @@ describe("ToolRegistry", () => {
107107
}),
108108
)
109109

110-
it.effect("reuses model definitions across requests", () =>
111-
Effect.gen(function* () {
112-
const service = yield* ToolRegistry.Service
113-
yield* service.register({ echo: make() }, { codemode: false })
114-
const first = yield* toolDefinitions(service)
115-
const second = yield* toolDefinitions(service)
116-
117-
expect(second[0]).toBe(first[0])
118-
}),
119-
)
120-
121110
it.effect("removes a scoped registration", () =>
122111
Effect.gen(function* () {
123112
const service = yield* ToolRegistry.Service

packages/plugin/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@opencode-ai/client": "workspace:*",
3030
"@opencode-ai/schema": "workspace:*",
3131
"@opencode-ai/sdk": "workspace:*",
32+
"@standard-schema/spec": "^1.1.0",
3233
"effect": "catalog:",
3334
"zod": "catalog:"
3435
},

packages/plugin/src/v2/effect/tool.ts

Lines changed: 146 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { Agent } from "@opencode-ai/schema/agent"
44
import type { LLM } from "@opencode-ai/schema/llm"
55
import { Session } from "@opencode-ai/schema/session"
66
import { SessionMessage } from "@opencode-ai/schema/session-message"
7-
import { Effect, JsonSchema, Schema, type Scope } from "effect"
7+
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
8+
import { Effect, JsonSchema, Schema } from "effect"
89
import type { Hooks, Transform } from "./registration.js"
910

1011
export interface Context {
@@ -20,7 +21,34 @@ export interface Progress {
2021
readonly content?: ReadonlyArray<Content>
2122
}
2223

23-
export type SchemaType<A> = Schema.Codec<A, any>
24+
export type StandardSchemaType<Input = unknown, Output = Input> = StandardSchemaV1<Input, Output> &
25+
StandardJSONSchemaV1<Input, Output>
26+
export type SchemaType<A> = Schema.Codec<A, any> | StandardSchemaType<any, A>
27+
type IsAny<A> = 0 extends 1 & A ? true : false
28+
export type InputValue<S> =
29+
IsAny<S> extends true
30+
? any
31+
: S extends Schema.Codec<infer A, any>
32+
? A
33+
: S extends StandardSchemaV1<any, infer A>
34+
? A
35+
: never
36+
export type OutputValue<S> =
37+
IsAny<S> extends true
38+
? any
39+
: S extends Schema.Codec<infer A, any>
40+
? A
41+
: S extends StandardSchemaV1<infer A, any>
42+
? A
43+
: never
44+
export type EncodedValue<S> =
45+
IsAny<S> extends true
46+
? any
47+
: S extends Schema.Codec<any, infer A>
48+
? A
49+
: S extends StandardSchemaV1<any, infer A>
50+
? A
51+
: never
2452

2553
type ToolDefinition = {
2654
readonly name: string
@@ -45,16 +73,6 @@ type ToolOutput = {
4573
readonly content: ReadonlyArray<LLM.ToolContent>
4674
}
4775

48-
declare const TypeId: unique symbol
49-
50-
export interface Definition<Input extends SchemaType<any>, Output extends SchemaType<any>> {
51-
readonly [TypeId]: {
52-
readonly _Input: Input
53-
readonly _Output: Output
54-
}
55-
}
56-
57-
export type AnyTool = Definition<any, any>
5876
export class Failure extends Schema.TaggedErrorClass<Failure>()("LLM.ToolFailure", {
5977
message: Schema.String,
6078
error: Schema.optional(Schema.Defect()),
@@ -70,7 +88,7 @@ export type Content =
7088
| { readonly type: "text"; readonly text: string }
7189
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
7290

73-
type Config<
91+
export type Definition<
7492
Input extends SchemaType<any>,
7593
Output extends SchemaType<any>,
7694
Structured extends SchemaType<any> = Output,
@@ -79,17 +97,15 @@ type Config<
7997
readonly input: Input
8098
readonly output: Output
8199
readonly structured?: Structured
100+
readonly permission?: string
82101
readonly toStructuredOutput?: (input: {
83-
readonly input: Schema.Schema.Type<Input>
84-
readonly output: Output["Encoded"]
85-
}) => Schema.Schema.Type<Structured>
86-
readonly execute: (
87-
input: Schema.Schema.Type<Input>,
88-
context: Context,
89-
) => Effect.Effect<Schema.Schema.Type<Output>, Failure>
102+
readonly input: InputValue<Input>
103+
readonly output: EncodedValue<Output>
104+
}) => OutputValue<Structured>
105+
readonly execute: (input: InputValue<Input>, context: Context) => Effect.Effect<OutputValue<Output>, Failure>
90106
readonly toModelOutput?: (input: {
91-
readonly input: Schema.Schema.Type<Input>
92-
readonly output: Output["Encoded"]
107+
readonly input: InputValue<Input>
108+
readonly output: EncodedValue<Output>
93109
}) => ReadonlyArray<Content>
94110
}
95111

@@ -103,109 +119,25 @@ export type DynamicOutput = {
103119
* time (MCP servers, plugin manifests). Input is passed through as `unknown`;
104120
* `execute` returns the already-projected structured value and model content.
105121
*/
106-
type DynamicConfig = {
122+
export type DynamicDefinition = {
107123
readonly description: string
108124
readonly jsonSchema: JsonSchema.JsonSchema
109125
readonly outputSchema?: JsonSchema.JsonSchema
110-
readonly execute: (input: unknown, context: Context) => Effect.Effect<DynamicOutput, Failure>
111-
}
112-
113-
type Runtime = {
114126
readonly permission?: string
115-
readonly definition: (name: string) => ToolDefinition
116-
readonly settle: (call: ToolCall, context: Context) => Effect.Effect<ToolOutput, Failure>
127+
readonly execute: (input: unknown, context: Context) => Effect.Effect<DynamicOutput, Failure>
117128
}
118129

119-
const runtimes = new WeakMap<AnyTool, Runtime>()
130+
export type AnyTool = Definition<any, any, any> | DynamicDefinition
120131

121132
export function make<
122133
Input extends SchemaType<any>,
123134
Output extends SchemaType<any>,
124135
Structured extends SchemaType<any> = Output,
125-
>(config: Config<Input, Output, Structured>): Definition<Input, Structured>
126-
export function make(config: DynamicConfig): AnyTool
127-
export function make(config: Config<any, any, any> | DynamicConfig): AnyTool {
128-
if ("jsonSchema" in config) return makeDynamic(config)
129-
return makeTyped(config)
130-
}
131-
132-
function makeTyped<
133-
Input extends SchemaType<any>,
134-
Output extends SchemaType<any>,
135-
Structured extends SchemaType<any> = Output,
136-
>(config: Config<Input, Output, Structured>): Definition<Input, Structured> {
137-
const tool = Object.freeze({}) as Definition<Input, Structured>
138-
const definitions = new Map<string, ToolDefinition>()
139-
runtimes.set(tool, {
140-
definition: (name) => {
141-
const cached = definitions.get(name)
142-
if (cached) return cached
143-
const definition: ToolDefinition = {
144-
name,
145-
description: config.description,
146-
inputSchema: toJsonSchema(config.input),
147-
outputSchema: toJsonSchema(config.structured ?? config.output),
148-
}
149-
definitions.set(name, definition)
150-
return definition
151-
},
152-
settle: (call, context) =>
153-
Schema.decodeUnknownEffect(config.input)(call.input).pipe(
154-
Effect.mapError((error) => new Failure({ message: `Invalid tool input: ${error.message}` })),
155-
Effect.flatMap((input) =>
156-
config.execute(input, context).pipe(
157-
Effect.flatMap((output) =>
158-
Schema.encodeEffect(config.output)(output).pipe(
159-
Effect.flatMap((output) => {
160-
if (!config.structured || !config.toStructuredOutput)
161-
return Effect.succeed({ output, structured: output })
162-
return Schema.encodeEffect(config.structured)(config.toStructuredOutput({ input, output })).pipe(
163-
Effect.map((structured) => ({ output, structured })),
164-
)
165-
}),
166-
Effect.mapError(
167-
(error) =>
168-
new Failure({
169-
message: `Tool returned an invalid value for its output schema: ${error.message}`,
170-
}),
171-
),
172-
),
173-
),
174-
Effect.map(({ output, structured }) => ({
175-
structured,
176-
content:
177-
config.toModelOutput?.({ input, output }).map(toModelContent) ??
178-
(typeof output === "string" ? [{ type: "text" as const, text: output }] : []),
179-
})),
180-
),
181-
),
182-
),
183-
})
184-
return tool
185-
}
186-
187-
function makeDynamic(config: DynamicConfig): AnyTool {
188-
const tool = Object.freeze({}) as AnyTool
189-
const definitions = new Map<string, ToolDefinition>()
190-
runtimes.set(tool, {
191-
definition: (name) => {
192-
const cached = definitions.get(name)
193-
if (cached) return cached
194-
const definition: ToolDefinition = {
195-
name,
196-
description: config.description,
197-
inputSchema: config.jsonSchema,
198-
outputSchema: config.outputSchema,
199-
}
200-
definitions.set(name, definition)
201-
return definition
202-
},
203-
settle: (call, context) =>
204-
config
205-
.execute(call.input, context)
206-
.pipe(Effect.map((output) => ({ structured: output.structured, content: output.content.map(toModelContent) }))),
207-
})
208-
return tool
136+
>(config: Definition<Input, Output, Structured>): Definition<Input, Output, Structured>
137+
export function make(config: DynamicDefinition): DynamicDefinition
138+
export function make(config: AnyTool): AnyTool
139+
export function make(config: AnyTool): AnyTool {
140+
return config
209141
}
210142

211143
function toModelContent(part: Content) {
@@ -230,23 +162,110 @@ export const registrationEntries = (tools: Readonly<Record<string, AnyTool>>, gr
230162
}
231163
})
232164

233-
export const withPermission = <Input extends SchemaType<any>, Output extends SchemaType<any>>(
234-
tool: Definition<Input, Output>,
165+
export const withPermission = <T extends AnyTool>(
166+
tool: T,
235167
permission: string,
236-
) => {
237-
const decorated = Object.freeze({}) as Definition<Input, Output>
238-
runtimes.set(decorated, { ...runtimeOf(tool), permission })
239-
return decorated
168+
): Omit<T, "permission"> & {
169+
readonly permission: string
170+
} => ({ ...tool, permission })
171+
172+
export const permission = (tool: AnyTool, name: string) => tool.permission ?? name
173+
174+
export const definition = (name: string, tool: AnyTool): ToolDefinition =>
175+
"jsonSchema" in tool
176+
? {
177+
name,
178+
description: tool.description,
179+
inputSchema: tool.jsonSchema,
180+
outputSchema: tool.outputSchema,
181+
}
182+
: {
183+
name,
184+
description: tool.description,
185+
inputSchema: inputJsonSchema(tool.input),
186+
outputSchema: outputJsonSchema(tool.structured ?? tool.output),
187+
}
188+
189+
export const settle = (tool: AnyTool, call: ToolCall, context: Context): Effect.Effect<ToolOutput, Failure> => {
190+
if ("jsonSchema" in tool)
191+
return tool
192+
.execute(call.input, context)
193+
.pipe(Effect.map((output) => ({ structured: output.structured, content: output.content.map(toModelContent) })))
194+
return decodeInput(tool.input, call.input).pipe(
195+
Effect.flatMap((input) =>
196+
tool.execute(input, context).pipe(
197+
Effect.flatMap((value) =>
198+
encodeOutput(tool.output, value).pipe(
199+
Effect.flatMap((output) => {
200+
if (!tool.structured || !tool.toStructuredOutput) return Effect.succeed({ output, structured: output })
201+
return encodeOutput(tool.structured, tool.toStructuredOutput({ input, output })).pipe(
202+
Effect.map((structured) => ({ output, structured })),
203+
)
204+
}),
205+
),
206+
),
207+
Effect.map(({ output, structured }) => ({
208+
structured,
209+
content:
210+
tool.toModelOutput?.({ input, output }).map(toModelContent) ??
211+
(typeof output === "string" ? [{ type: "text" as const, text: output }] : []),
212+
})),
213+
),
214+
),
215+
)
216+
}
217+
218+
function decodeInput(schema: SchemaType<any>, value: unknown): Effect.Effect<any, Failure> {
219+
if (Schema.isSchema(schema))
220+
return Schema.decodeUnknownEffect(schema)(value).pipe(
221+
Effect.mapError((error) => new Failure({ message: `Invalid tool input: ${error.message}` })),
222+
)
223+
return validateStandard(schema, value, "Invalid tool input")
224+
}
225+
226+
function encodeOutput(schema: SchemaType<any>, value: unknown): Effect.Effect<any, Failure> {
227+
if (Schema.isSchema(schema))
228+
return Schema.encodeEffect(schema)(value).pipe(
229+
Effect.mapError(
230+
(error) => new Failure({ message: `Tool returned an invalid value for its output schema: ${error.message}` }),
231+
),
232+
)
233+
return validateStandard(schema, value, "Tool returned an invalid value for its output schema")
240234
}
241235

242-
export const permission = (tool: AnyTool, name: string) => runtimeOf(tool).permission ?? name
243-
export const definition = (name: string, tool: AnyTool) => runtimeOf(tool).definition(name)
244-
export const settle = (tool: AnyTool, call: ToolCall, context: Context) => runtimeOf(tool).settle(call, context)
236+
function validateStandard(schema: StandardSchemaType, value: unknown, prefix: string): Effect.Effect<unknown, Failure> {
237+
const result = Effect.try({
238+
try: () => schema["~standard"].validate(value),
239+
catch: (error) => new Failure({ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}` }),
240+
})
241+
return result.pipe(
242+
Effect.flatMap((result) =>
243+
result instanceof Promise
244+
? Effect.tryPromise({
245+
try: () => result,
246+
catch: (error) =>
247+
new Failure({ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}` }),
248+
})
249+
: Effect.succeed(result),
250+
),
251+
Effect.flatMap((result) =>
252+
result.issues
253+
? Effect.fail(new Failure({ message: `${prefix}: ${result.issues.map((issue) => issue.message).join(", ")}` }))
254+
: Effect.succeed(result.value),
255+
),
256+
)
257+
}
258+
259+
function inputJsonSchema(schema: SchemaType<any>): JsonSchema.JsonSchema {
260+
if (!Schema.isSchema(schema))
261+
return schema["~standard"].jsonSchema.input({ target: "draft-2020-12" }) as JsonSchema.JsonSchema
262+
return toJsonSchema(schema)
263+
}
245264

246-
function runtimeOf(tool: AnyTool) {
247-
const runtime = runtimes.get(tool)
248-
if (!runtime) throw new TypeError("Invalid Tool value")
249-
return runtime
265+
function outputJsonSchema(schema: SchemaType<any>): JsonSchema.JsonSchema {
266+
if (!Schema.isSchema(schema))
267+
return schema["~standard"].jsonSchema.output({ target: "draft-2020-12" }) as JsonSchema.JsonSchema
268+
return toJsonSchema(schema)
250269
}
251270

252271
function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema {

0 commit comments

Comments
 (0)