fix(core): harden unified tool runtime (#31171)

This commit is contained in:
Kit Langton 2026-06-06 21:55:34 -04:00 committed by GitHub
commit eb9a683b40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 435 additions and 237 deletions

View file

@ -3,7 +3,7 @@ export { Agent } from "./agent"
export { Model } from "./model"
export { OpenCode } from "./opencode"
export { Session } from "./session"
export * as Tool from "./tool"
export { Tool } from "./tool"
export { Location } from "./location"
export { Prompt } from "../session/prompt"
export { AbsolutePath } from "../schema"

View file

@ -4,7 +4,7 @@ import { Effect, Scope } from "effect"
import type { AnyTool, RegistrationError } from "../tool/tool"
export { Failure, RegistrationError, make } from "../tool/tool"
export type { AnyTool, Content, Context } from "../tool/tool"
export type { AnyTool, Content, Context, Definition } from "../tool/tool"
export interface Interface {
/**

View file

@ -320,6 +320,11 @@ export const layer = Layer.effect(
yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
}
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
const failure = Cause.squash(settled.cause)
const message = failure instanceof Error ? failure.message : String(failure)
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
}
if (publisher.hasProviderError())
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
if (stream._tag === "Success" && !publisher.hasProviderError())

View file

@ -61,30 +61,40 @@ export function create<State extends Objectish, Editor>(options: Options<State,
state = next
})
const rebuild = Effect.fn("State.rebuild")(function* () {
const rebuild = Effect.fnUntraced(function* () {
const next = options.initial()
const api = options.editor(next as Draft<State>)
for (const transform of transforms)
yield* Effect.sync(() => transform.update(api)).pipe(Effect.withSpan("State.rebuild.update", {}))
yield* commit(next)
}, semaphore.withPermit)
})
return {
get: () => state,
transform: Effect.fn("State.transform")(function* () {
const transform = { update: (_editor: Editor) => {} }
transforms = [...transforms, transform]
const scope = yield* Scope.Scope
yield* Scope.addFinalizer(
scope,
Effect.sync(() => {
transforms = transforms.filter((item) => item !== transform)
}).pipe(Effect.andThen(rebuild())),
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const transform = { update: (_editor: Editor) => {} }
transforms = [...transforms, transform]
yield* Scope.addFinalizer(
scope,
semaphore.withPermit(
Effect.sync(() => {
transforms = transforms.filter((item) => item !== transform)
}).pipe(Effect.andThen(rebuild())),
),
)
return (update: Transform<Editor>) =>
Effect.uninterruptible(
semaphore.withPermit(
Effect.sync(() => {
transform.update = update
}).pipe(Effect.andThen(rebuild())),
),
)
}),
)
return Effect.fnUntraced(function* (update: Transform<Editor>) {
transform.update = update
yield* rebuild()
})
}),
update: Effect.fn("State.update")(function* (update, reason) {
const api = options.editor(state as Draft<State>)

View file

@ -11,7 +11,6 @@ import type { ToolOutput } from "@opencode-ai/llm"
export const MAX_LINES = 2_000
export const MAX_BYTES = 50 * 1024
export const MAX_INLINE_MEDIA_BYTES = 5 * 1024 * 1024
export const RETENTION = Duration.days(7)
export const MANAGED_DIRECTORY = "tool-output"
@ -32,13 +31,7 @@ export class StorageError extends Schema.TaggedErrorClass<StorageError>()("ToolO
cause: Schema.Defect,
}) {}
export class MediaLimitError extends Schema.TaggedErrorClass<MediaLimitError>()("ToolOutputStore.MediaLimitError", {
mime: Schema.String,
bytes: Schema.Int,
limit: Schema.Int,
}) {}
export type Error = StorageError | MediaLimitError
export type Error = StorageError
export interface Interface {
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
@ -139,37 +132,33 @@ export const layer = Layer.effect(
const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
const outputLimits = yield* limits()
const media = input.output.content.filter((item) => item.type === "file")
let mediaBytes = 0
for (const item of media) {
if (item.source.type !== "data") continue
mediaBytes += Buffer.byteLength(item.source.data, "utf-8")
if (mediaBytes > MAX_INLINE_MEDIA_BYTES)
return yield* new MediaLimitError({ mime: item.mime, bytes: mediaBytes, limit: MAX_INLINE_MEDIA_BYTES })
}
const contextual = {
structured: media.length > 0 ? {} : input.output.structured,
content: input.output.content.filter((item) => item.type === "text"),
}
const encoded = yield* Effect.try({
try: () => JSON.stringify(contextual, null, 2),
catch: (cause) => new StorageError({ operation: "encode", cause }),
})
if (lineCount(encoded) <= outputLimits.maxLines && Buffer.byteLength(encoded, "utf-8") <= outputLimits.maxBytes)
const text = input.output.content.filter((item) => item.type === "text")
const contextual =
input.output.content.length === 0
? yield* Effect.try({
try: () => JSON.stringify(input.output.structured, null, 2) ?? String(input.output.structured),
catch: (cause) => new StorageError({ operation: "encode", cause }),
})
: text.map((item) => item.text).join("")
if (
lineCount(contextual) <= outputLimits.maxLines &&
Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes
)
return {
output: { structured: contextual.structured, content: input.output.content },
output: input.output,
outputPaths: [],
}
const outputPath = yield* write(encoded)
const outputPath = yield* write(contextual)
const marker = `... output truncated; full content saved to ${outputPath} ...`
return {
output: {
structured: {},
structured: input.output.structured,
content: [
{
type: "text" as const,
text: boundedPreview(encoded, marker, outputLimits.maxLines, outputLimits.maxBytes),
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
},
...media,
],

View file

@ -56,3 +56,4 @@ Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOut
- Plugin boot has not been redesigned to register canonical tools through `Tools.Service`; do not redesign it as part of leaf migrations.
- MCP and future Session-scoped registrations still need an explicit canonical registration design.
- The public Session result shape currently exposes managed `outputPaths`; full storage encapsulation requires a future opaque managed-output reference design.

View file

@ -12,7 +12,7 @@ import { Tools } from "./tools"
export const name = "apply_patch"
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
patchText: Schema.String.annotate({
description: "The full patch text describing add, update, and delete operations",
}),
@ -24,10 +24,10 @@ export const Applied = Schema.Struct({
target: Schema.String,
})
export const Success = Schema.Struct({ applied: Schema.Array(Applied) })
export type Success = typeof Success.Type
export const Output = Schema.Struct({ applied: Schema.Array(Applied) })
export type Output = typeof Output.Type
export const toModelOutput = (output: Success) =>
export const toModelOutput = (output: Output) =>
[
"Applied patch sequentially:",
...output.applied.map(
@ -57,8 +57,8 @@ export const layer = Layer.effectDiscard(
Tool.make({
description:
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
input: Parameters,
output: Success,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []

View file

@ -18,7 +18,7 @@ export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
export const MAX_CAPTURE_BYTES = 1024 * 1024
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
command: Schema.String.annotate({ description: "Shell command string to execute" }),
workdir: Schema.String.pipe(Schema.optional).annotate({
description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.",
@ -33,7 +33,7 @@ export const Parameters = Schema.Struct({
}),
})
const Success = Schema.Struct({
const Output = Schema.Struct({
command: Schema.String,
cwd: Schema.String,
exitCode: Schema.Number.pipe(Schema.optional),
@ -46,7 +46,7 @@ const Success = Schema.Struct({
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
})
type Success = typeof Success.Type
type Output = typeof Output.Type
const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh")
@ -62,7 +62,7 @@ const captureNotice = (stdoutTruncated: boolean, stderrTruncated: boolean) => {
return undefined
}
const modelOutput = (output: Success) => {
const modelOutput = (output: Output) => {
const warnings = output.warnings?.length
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
: ""
@ -117,8 +117,8 @@ export const layer = Layer.effectDiscard(
.register({
[name]: Tool.make({
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
input: Parameters,
output: Success,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: modelOutput(output) })],
execute: (input, context) =>
Effect.gen(function* () {

View file

@ -18,7 +18,7 @@ import { Tools } from "./tools"
export const name = "edit"
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
path: Schema.String.annotate({
description:
"File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
@ -30,14 +30,14 @@ export const Parameters = Schema.Struct({
}),
})
export const Success = Schema.Struct({
export const Output = Schema.Struct({
operation: Schema.Literal("write"),
target: Schema.String,
resource: Schema.String,
existed: Schema.Boolean,
replacements: Schema.Number,
})
export type Success = typeof Success.Type
export type Output = typeof Output.Type
const normalizeLineEndings = (text: string) => text.replaceAll("\r\n", "\n")
const detectLineEnding = (text: string): "\n" | "\r\n" => (text.includes("\r\n") ? "\r\n" : "\n")
@ -70,7 +70,7 @@ const previewLines = (value: string, prefix: "+" | "-") => {
return shown
}
export const toModelOutput = (output: Success, oldString: string, newString: string) =>
export const toModelOutput = (output: Output, oldString: string, newString: string) =>
[
`Edited file successfully: ${output.resource}`,
`Replacements: ${output.replacements}`,
@ -101,8 +101,8 @@ export const layer = Layer.effectDiscard(
Tool.make({
description:
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
input: Parameters,
output: Success,
input: Input,
output: Output,
toModelOutput: ({ input, output }) => [
toolText({ type: "text", text: toModelOutput(output, input.oldString, input.newString) }),
],
@ -188,7 +188,7 @@ export const layer = Layer.effectDiscard(
content: joinBom(next.text, source.bom || next.bom),
}),
)
return { ...result, replacements } satisfies Success
return { ...result, replacements } satisfies Output
})
},
}),

View file

@ -10,7 +10,7 @@ import { Tools } from "./tools"
export const name = "glob"
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
pattern: LocationSearch.FilesInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
path: LocationSearch.FilesInput.fields.path.annotate({
description: "Relative directory to search. Defaults to the active Location.",
@ -56,7 +56,7 @@ export const layer = Layer.effectDiscard(
[name]: Tool.make({
description:
"Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
input: Parameters,
input: Input,
output: LocationSearch.FilesResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
execute: (input, context) =>

View file

@ -11,7 +11,7 @@ import { Tools } from "./tools"
export const name = "grep"
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
pattern: LocationSearch.GrepInput.fields.pattern.annotate({
description: "Regex pattern to search for in file contents",
}),
@ -29,10 +29,10 @@ export const Parameters = Schema.Struct({
}),
})
type Success = typeof LocationSearch.GrepResult.Encoded
type Output = typeof LocationSearch.GrepResult.Encoded
/** Format raw Location search matches into the familiar concise model output. */
export const toModelOutput = (output: Success) => {
export const toModelOutput = (output: Output) => {
const lines = output.items.length === 0 ? ["No files found"] : [`Found ${output.items.length} matches`]
let current = ""
for (const match of output.items) {
@ -71,7 +71,7 @@ export const layer = Layer.effectDiscard(
[name]: Tool.make({
description:
"Search file contents by regular expression within the active Location, a named project reference, or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
input: Parameters,
input: Input,
output: LocationSearch.GrepResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
execute: (input, context) =>

View file

@ -20,14 +20,14 @@ Usage notes:
- Answers are returned as arrays of labels; set \`multiple: true\` to allow selecting more than one
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
questions: Schema.Array(QuestionV2.Prompt).annotate({ description: "Questions to ask" }),
})
export const Success = Schema.Struct({
export const Output = Schema.Struct({
answers: Schema.Array(QuestionV2.Answer),
})
export type Success = typeof Success.Type
export type Output = typeof Output.Type
export const toModelOutput = (
questions: ReadonlyArray<QuestionV2.Prompt>,
@ -52,8 +52,8 @@ export const layer = Layer.effectDiscard(
.register({
[name]: Tool.make({
description,
input: Parameters,
output: Success,
input: Input,
output: Output,
toModelOutput: ({ input, output }) => [
toolText({ type: "text", text: toModelOutput(input.questions, output.answers) }),
],

View file

@ -20,7 +20,7 @@ const LocationInput = Schema.Struct({
}),
})
const Input = LocationInput
const Success = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage])
const Output = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage])
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
@ -35,7 +35,7 @@ export const layer = Layer.effectDiscard(
description:
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page relative to the current location. Absolute paths are accepted only for managed tool-output files.",
input: Input,
output: Success,
output: Output,
toModelOutput: ({ input, output }) => {
if (!("type" in output) || output.type !== "binary" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) return []
return [

View file

@ -1,6 +1,6 @@
export * as ToolRegistry from "./registry"
import { Tool as LlmTool, ToolOutput, type ToolCall, type ToolSettlement } from "@opencode-ai/llm"
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolSettlement } from "@opencode-ai/llm"
import { Context, Effect, Layer, Scope } from "effect"
import { AgentV2 } from "../agent"
import { PermissionV2 } from "../permission"
@ -9,7 +9,7 @@ import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard"
import { ApplicationTools } from "./application-tools"
import { Tool } from "./tool"
import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool"
import { Tools } from "./tools"
export type ExecuteInput = {
@ -22,13 +22,11 @@ export type ExecuteInput = {
export interface Interface {
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
/** Internal registration capability exposed publicly only through Tools.Service. */
readonly register: (
tools: Readonly<Record<string, Tool.AnyTool>>,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
}
export interface Materialization {
readonly definitions: ReadonlyArray<ReturnType<typeof LlmTool.toDefinitions>[number]>
readonly definitions: ReadonlyArray<ToolDefinition>
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement, ToolOutputStore.Error>
}
@ -43,7 +41,7 @@ const registryLayer = Layer.effect(
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const resources = yield* ToolOutputStore.Service
type Registration = { readonly identity: object; readonly tool: Tool.AnyTool }
type Registration = { readonly identity: object; readonly tool: AnyTool }
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) {
@ -58,7 +56,7 @@ const registryLayer = Layer.effect(
}
if (advertised && registration.identity !== advertised)
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
const pending = yield* Tool.settle(registration.tool, input.call, {
const pending = yield* settle(registration.tool, input.call, {
sessionID: input.sessionID,
agent: input.agent,
assistantMessageID: input.assistantMessageID,
@ -84,17 +82,21 @@ const registryLayer = Layer.effect(
register: Effect.fn("ToolRegistry.register")(function* (tools) {
const entries = Object.entries(tools)
if (entries.length === 0) return
yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true })
const token = {}
for (const [name, tool] of entries)
local.set(name, [...(local.get(name) ?? []), { token, registration: { identity: {}, tool } }])
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const [name] of entries) {
const registrations = local.get(name)?.filter((registration) => registration.token !== token) ?? []
if (registrations.length > 0) local.set(name, registrations)
else local.delete(name)
}
yield* Effect.forEach(entries, ([name]) => validateName(name), { discard: true })
yield* Effect.uninterruptible(
Effect.gen(function* () {
const token = {}
for (const [name, tool] of entries)
local.set(name, [...(local.get(name) ?? []), { token, registration: { identity: {}, tool } }])
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const [name] of entries) {
const registrations = local.get(name)?.filter((registration) => registration.token !== token) ?? []
if (registrations.length > 0) local.set(name, registrations)
else local.delete(name)
}
}),
)
}),
)
}),
@ -105,9 +107,9 @@ const registryLayer = Layer.effect(
if (registration) registrations.set(name, registration)
}
for (const [name, registration] of registrations)
if (whollyDisabled(Tool.permission(registration.tool, name), permissions)) registrations.delete(name)
if (whollyDisabled(permission(registration.tool, name), permissions)) registrations.delete(name)
return {
definitions: Array.from(registrations, ([name, registration]) => Tool.definition(name, registration.tool)),
definitions: Array.from(registrations, ([name, registration]) => definition(name, registration.tool)),
settle: (input) => {
const registration = registrations.get(input.call.name)
if (registration) return settleWith(input, registration.identity)

View file

@ -14,11 +14,11 @@ import { Tools } from "./tools"
export const name = "skill"
const FILE_LIMIT = 10
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
name: Schema.String.annotate({ description: "The name of the skill from the available skills list" }),
})
export const Success = Schema.Struct({
export const Output = Schema.Struct({
name: Schema.String,
directory: Schema.String,
output: Schema.String,
@ -66,8 +66,8 @@ export const layer = Layer.effectDiscard(
.register({
[name]: Tool.make({
description,
input: Parameters,
output: Success,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
execute: (input, context) =>
Effect.gen(function* () {

View file

@ -9,16 +9,16 @@ import { Tools } from "./tools"
export const name = "todowrite"
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
todos: Schema.Array(SessionTodo.Info).annotate({ description: "The updated todo list" }),
})
export const Success = Schema.Struct({
export const Output = Schema.Struct({
todos: Schema.Array(SessionTodo.Info),
})
export type Success = typeof Success.Type
export type Output = typeof Output.Type
export const toModelOutput = (output: Success) => JSON.stringify(output.todos, null, 2)
export const toModelOutput = (output: Output) => JSON.stringify(output.todos, null, 2)
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
@ -31,8 +31,8 @@ export const layer = Layer.effectDiscard(
[name]: Tool.make({
description:
"Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.",
input: Parameters,
output: Success,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
execute: (input, context) =>
Effect.gen(function* () {

View file

@ -1,7 +1,7 @@
export * as Tool from "./tool"
import { Tool as LlmTool, ToolFailure, ToolOutput, type ToolCall } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
import { ToolDefinition, ToolFailure, ToolOutput, type ToolCall } from "@opencode-ai/llm"
import { Effect, JsonSchema, Schema } from "effect"
import type { AgentV2 } from "../agent"
import type { SessionMessage } from "../session/message"
import type { SessionSchema } from "../session/schema"
@ -17,14 +17,14 @@ export type SchemaType<A> = Schema.Codec<A, any, never, never>
declare const TypeId: unique symbol
export interface Tool<Input extends SchemaType<any>, Output extends SchemaType<any>> {
export interface Definition<Input extends SchemaType<any>, Output extends SchemaType<any>> {
readonly [TypeId]: {
readonly _Input: Input
readonly _Output: Output
}
}
export type AnyTool = Tool<any, any>
export type AnyTool = Definition<any, any>
export const Failure = ToolFailure
export type Failure = ToolFailure
@ -53,7 +53,7 @@ type Config<Input extends SchemaType<any>, Output extends SchemaType<any>> = {
type Runtime = {
readonly permission?: string
readonly definition: (name: string) => ReturnType<typeof LlmTool.toDefinitions>[number]
readonly definition: (name: string) => ToolDefinition
readonly settle: (call: ToolCall, context: Context) => Effect.Effect<ToolOutput, ToolFailure>
}
@ -61,16 +61,19 @@ const runtimes = new WeakMap<AnyTool, Runtime>()
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
config: Config<Input, Output>,
): Tool<Input, Output> {
const tool = Object.freeze({}) as Tool<Input, Output>
const definitions = new Map<string, ReturnType<typeof LlmTool.toDefinitions>[number]>()
): Definition<Input, Output> {
const tool = Object.freeze({}) as Definition<Input, Output>
const definitions = new Map<string, ToolDefinition>()
runtimes.set(tool, {
definition: (name) => {
const cached = definitions.get(name)
if (cached) return cached
const definition = LlmTool.toDefinitions({
[name]: LlmTool.make({ description: config.description, parameters: config.input, success: config.output }),
})[0]
const definition = new ToolDefinition({
name,
description: config.description,
inputSchema: toJsonSchema(config.input),
outputSchema: toJsonSchema(config.output),
})
definitions.set(name, definition)
return definition
},
@ -117,10 +120,10 @@ export const validateName = (name: string) =>
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
export const withPermission = <Input extends SchemaType<any>, Output extends SchemaType<any>>(
tool: Tool<Input, Output>,
tool: Definition<Input, Output>,
permission: string,
) => {
const decorated = Object.freeze({}) as Tool<Input, Output>
const decorated = Object.freeze({}) as Definition<Input, Output>
runtimes.set(decorated, { ...runtimeOf(tool), permission })
return decorated
}
@ -134,3 +137,9 @@ function runtimeOf(tool: AnyTool) {
if (!runtime) throw new TypeError("Invalid Core Tool value")
return runtime
}
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 }
}

View file

@ -16,11 +16,11 @@ export const MAX_TIMEOUT_SECONDS = 120
export const description = `Fetch content from an HTTP or HTTPS URL and return it as text, markdown, or HTML. Markdown is the default.
Use a more targeted tool when one is available. This tool is read-only. Large text results are truncated and saved to a managed file that ordinary Read, Grep, and Bash tools can inspect.`
Use a more targeted tool when one is available. This tool is read-only. Large text results may be replaced with a preview while the complete output is retained in managed storage.`
const Timeout = Schema.Number.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_TIMEOUT_SECONDS))
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
url: Schema.String.annotate({ description: "The HTTP or HTTPS URL to fetch content from" }),
format: Schema.Literals(["text", "markdown", "html"])
.annotate({ description: "The format to return the content in. Defaults to markdown." })
@ -30,14 +30,14 @@ export const Parameters = Schema.Struct({
}),
})
const Success = Schema.Struct({
const Output = Schema.Struct({
url: Schema.String,
contentType: Schema.String,
format: Parameters.fields.format,
format: Input.fields.format,
output: Schema.String,
})
type Format = (typeof Parameters.Type)["format"]
type Format = (typeof Input.Type)["format"]
const acceptHeader = (format: Format) => {
switch (format) {
@ -134,8 +134,8 @@ export const layer = Layer.effectDiscard(
.register({
[name]: Tool.make({
description,
input: Parameters,
output: Success,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
execute: (input, context) =>
Effect.gen(function* () {

View file

@ -33,7 +33,7 @@ Optional controls support result count, live crawling ('fallback' or 'preferred'
The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
query: Schema.String.annotate({ description: "Websearch query" }),
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({
description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})`,
@ -176,7 +176,7 @@ const callMcp = <F extends Schema.Struct.Fields>(
)
})
const Success = Schema.Struct({
const Output = Schema.Struct({
provider: Provider,
text: Schema.String,
})
@ -192,8 +192,8 @@ export const layer = Layer.effectDiscard(
.register({
[name]: Tool.make({
description,
input: Parameters,
output: Success,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.text })],
execute: (input, context) => {
const provider = selectProvider(context.sessionID, config, config.provider)

View file

@ -18,7 +18,7 @@ import { Tools } from "./tools"
export const name = "write"
// TODO: Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.
export const Parameters = Schema.Struct({
export const Input = Schema.Struct({
path: Schema.String.annotate({
description:
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
@ -26,15 +26,15 @@ export const Parameters = Schema.Struct({
content: Schema.String.annotate({ description: "Content to write to the file" }),
})
export const Success = Schema.Struct({
export const Output = Schema.Struct({
operation: Schema.Literal("write"),
target: Schema.String,
resource: Schema.String,
existed: Schema.Boolean,
})
export type Success = typeof Success.Type
export type Output = typeof Output.Type
export const toModelOutput = (output: Success) =>
export const toModelOutput = (output: Output) =>
`${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}`
/** Deferred V2 write UX integrations remain visible at the model-facing seam. */
@ -56,8 +56,8 @@ export const layer = Layer.effectDiscard(
Tool.make({
description:
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
input: Parameters,
output: Success,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
execute: (input, context) =>
Effect.gen(function* () {