feat(plugin): add tool result content API

This commit is contained in:
Kit Langton 2026-06-30 23:20:18 -04:00
commit 3e583a3f5a
25 changed files with 316 additions and 197 deletions

View file

@ -6,11 +6,12 @@ import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Effect, JsonSchema, Schema, type Scope } from "effect"
export interface Context {
export interface Context<Output = unknown> {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly assistantMessageID: SessionMessage.ID
readonly toolCallID: string
readonly progress: (state: State<Output>) => Effect.Effect<void>
}
export type SchemaType<A> = Schema.Codec<A, any>
@ -28,6 +29,20 @@ export type AnyTool = Definition<any, any>
export const Failure = ToolFailure
export type Failure = ToolFailure
const ResultTypeId = Symbol("@opencode-ai/plugin/Tool.Result")
export interface State<Output> {
readonly output: Output
readonly content?: ReadonlyArray<Content>
}
export interface Result<Output> extends State<Output> {
readonly [ResultTypeId]: Output
}
export const result = <Output>(state: State<Output>): Result<Output> =>
Object.freeze({ ...state, [ResultTypeId]: state.output })
export class RegistrationError extends Schema.TaggedErrorClass<RegistrationError>()("Tool.RegistrationError", {
name: Schema.String,
message: Schema.String,
@ -37,72 +52,72 @@ export type Content =
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
type Config<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
> = {
type Config<Input extends SchemaType<any>, Output extends SchemaType<any>> = {
readonly description: string
readonly input: Input
readonly output: Output
readonly structured?: Structured
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>, ToolFailure>
readonly toModelOutput?: (input: {
readonly input: Schema.Schema.Type<Input>
readonly output: Output["Encoded"]
}) => ReadonlyArray<Content>
context: Context<Schema.Schema.Type<Output>>,
) => Effect.Effect<Schema.Schema.Type<Output> | Result<Schema.Schema.Type<Output>>, ToolFailure>
}
export type DynamicOutput = {
readonly structured: unknown
readonly content: ReadonlyArray<Content>
}
export type DynamicOutput = Result<unknown>
/**
* 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.
* Return `Tool.result(...)` when the output needs explicit model content.
*/
type DynamicConfig = {
readonly description: string
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
readonly execute: (input: unknown, context: Context) => Effect.Effect<DynamicOutput, ToolFailure>
readonly execute: (input: unknown, context: Context) => Effect.Effect<unknown, ToolFailure>
}
export interface RuntimeContext extends Omit<Context, "progress"> {
readonly progress?: (output: ToolOutput) => Effect.Effect<void, unknown>
}
type Runtime = {
readonly permission?: string
readonly definition: (name: string) => ToolDefinition
readonly settle: (call: ToolCall, context: Context) => Effect.Effect<ToolOutput, ToolFailure>
readonly settle: (call: ToolCall, context: RuntimeContext) => Effect.Effect<ToolOutput, ToolFailure>
}
const runtimes = new WeakMap<AnyTool, Runtime>()
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<Input extends SchemaType<any>, Output extends SchemaType<any>>(
config: Config<Input, Output>,
): Definition<Input, Output>
export function make(config: DynamicConfig): AnyTool
export function make(config: Config<any, any, any> | DynamicConfig): AnyTool {
export function make(config: Config<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>
function makeTyped<Input extends SchemaType<any>, Output extends SchemaType<any>>(
config: Config<Input, Output>,
): Definition<Input, Output> {
const tool = Object.freeze({}) as Definition<Input, Output>
const definitions = new Map<string, ToolDefinition>()
const project = (
value: Schema.Schema.Type<Output> | Result<Schema.Schema.Type<Output>>,
): Effect.Effect<ToolOutput, ToolFailure> => {
const state = stateOf(value)
return Schema.encodeEffect(config.output)(state.output).pipe(
Effect.map((output) => ToolOutput.make(output, contentOf(output, state.content))),
Effect.mapError(
(error) =>
new ToolFailure({
message: `Tool returned an invalid value for its output schema: ${error.message}`,
}),
),
)
}
runtimes.set(tool, {
definition: (name) => {
const cached = definitions.get(name)
@ -111,7 +126,7 @@ function makeTyped<
name,
description: config.description,
inputSchema: toJsonSchema(config.input),
outputSchema: toJsonSchema(config.structured ?? config.output),
outputSchema: toJsonSchema(config.output),
})
definitions.set(name, definition)
return definition
@ -120,31 +135,13 @@ function makeTyped<
Schema.decodeUnknownEffect(config.input)(call.input).pipe(
Effect.mapError((error) => new ToolFailure({ 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 ToolFailure({
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 }] : []),
})),
),
config
.execute(input, {
...context,
progress: (state) =>
project(result(state)).pipe(Effect.flatMap(context.progress ?? (() => Effect.void)), Effect.ignore),
})
.pipe(Effect.flatMap(project)),
),
),
})
@ -154,6 +151,10 @@ function makeTyped<
function makeDynamic(config: DynamicConfig): AnyTool {
const tool = Object.freeze({}) as AnyTool
const definitions = new Map<string, ToolDefinition>()
const project = (value: unknown) => {
const state = stateOf(value)
return ToolOutput.make(state.output, contentOf(state.output, state.content))
}
runtimes.set(tool, {
definition: (name) => {
const cached = definitions.get(name)
@ -169,12 +170,28 @@ function makeDynamic(config: DynamicConfig): AnyTool {
},
settle: (call, context) =>
config
.execute(call.input, context)
.pipe(Effect.map((output) => ({ structured: output.structured, content: output.content.map(toModelContent) }))),
.execute(call.input, {
...context,
progress: (state) => context.progress?.(project(result(state))).pipe(Effect.ignore) ?? Effect.void,
})
.pipe(Effect.map(project)),
})
return tool
}
function stateOf<Output>(value: Output | Result<Output>): State<Output> {
if (isResult(value)) return value
return { output: value }
}
function isResult(value: unknown): value is Result<unknown> {
return typeof value === "object" && value !== null && ResultTypeId in value
}
function contentOf(output: unknown, content: ReadonlyArray<Content> | undefined) {
return content?.map(toModelContent) ?? (typeof output === "string" ? [{ type: "text" as const, text: output }] : [])
}
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 }
@ -199,7 +216,7 @@ export const withPermission = <Input extends SchemaType<any>, Output extends Sch
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)
export const settle = (tool: AnyTool, call: ToolCall, context: RuntimeContext) => runtimeOf(tool).settle(call, context)
function runtimeOf(tool: AnyTool) {
const runtime = runtimes.get(tool)