mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-25 23:03:35 +00:00
314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
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>
|
|
}
|