Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f7c6c33d9 |
@@ -4,7 +4,7 @@ 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` re-exports the opaque canonical `Tool.make({ description, input, output, execute })` value. 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.
|
||||
|
||||
@@ -12,7 +12,7 @@ Do not add a second executable entry type, registry-owned executor, authorizatio
|
||||
|
||||
## 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 use `input` and `output` terminology. Executors return `{ structured, content }` directly. A tool value is opaque: its codecs, executor, definition derivation, and catalog permission declaration are private runtime details.
|
||||
|
||||
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:
|
||||
|
||||
@@ -46,7 +46,7 @@ Definition filtering is catalog visibility, not execution authorization. A call
|
||||
|
||||
## Output
|
||||
|
||||
Built-ins return complete validated domain output. `ToolRegistry.Materialization.settle` is the only execution and generic model-output bounding boundary and owns managed retention paths.
|
||||
Built-ins return complete structured output and model content together. `ToolRegistry.Materialization.settle` validates and encodes the structured value, then applies generic model-output bounding and owns managed retention paths.
|
||||
|
||||
Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss, but it does not run model-output truncation or return a managed `outputPath`.
|
||||
|
||||
|
||||
@@ -70,7 +70,6 @@ export const Plugin = {
|
||||
"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: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
||||
execute: (input, context) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const fail = (path: string) => {
|
||||
@@ -184,7 +183,13 @@ export const Plugin = {
|
||||
{ discard: true },
|
||||
)
|
||||
return { applied, files: patchFiles }
|
||||
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
|
||||
}).pipe(
|
||||
Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))),
|
||||
Effect.map((structured) => ({
|
||||
structured,
|
||||
content: [{ type: "text", text: toModelOutput(structured) }],
|
||||
})),
|
||||
)
|
||||
},
|
||||
}),
|
||||
"edit",
|
||||
|
||||
@@ -101,9 +101,6 @@ export const Plugin = {
|
||||
"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.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ input, output }) => [
|
||||
{ type: "text", text: toModelOutput(output, input.oldString, input.newString) },
|
||||
],
|
||||
execute: (input, context) => {
|
||||
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
@@ -204,7 +201,12 @@ export const Plugin = {
|
||||
],
|
||||
replacements,
|
||||
} satisfies Output
|
||||
})
|
||||
}).pipe(
|
||||
Effect.map((structured) => ({
|
||||
structured,
|
||||
content: [{ type: "text", text: toModelOutput(structured, input.oldString, input.newString) }],
|
||||
})),
|
||||
)
|
||||
},
|
||||
}),
|
||||
"edit",
|
||||
|
||||
@@ -49,14 +49,6 @@ export const Plugin = {
|
||||
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [
|
||||
{
|
||||
type: "text",
|
||||
text: toModelOutput(
|
||||
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
|
||||
),
|
||||
},
|
||||
],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
@@ -102,6 +94,20 @@ export const Plugin = {
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}` }),
|
||||
),
|
||||
Effect.map((structured) => ({
|
||||
structured,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: toModelOutput(
|
||||
structured.map((entry) => ({
|
||||
...entry,
|
||||
path: path.resolve(location.directory, entry.path),
|
||||
})),
|
||||
),
|
||||
},
|
||||
],
|
||||
})),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -63,17 +63,6 @@ export const Plugin = {
|
||||
"Search file contents by regular expression within the active Location 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: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [
|
||||
{
|
||||
type: "text",
|
||||
text: toModelOutput(
|
||||
output.map((match) => ({
|
||||
...match,
|
||||
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
|
||||
})),
|
||||
),
|
||||
},
|
||||
],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
@@ -133,6 +122,20 @@ export const Plugin = {
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to grep for ${input.pattern}` }),
|
||||
),
|
||||
Effect.map((structured) => ({
|
||||
structured,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: toModelOutput(
|
||||
structured.map((match) => ({
|
||||
...match,
|
||||
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
|
||||
})),
|
||||
),
|
||||
},
|
||||
],
|
||||
})),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -54,9 +54,6 @@ export const Plugin = {
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ input, output }) => [
|
||||
{ type: "text", text: toModelOutput(input.questions, output.answers) },
|
||||
],
|
||||
execute: (input, context) =>
|
||||
permission
|
||||
.assert({
|
||||
@@ -77,7 +74,10 @@ export const Plugin = {
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
),
|
||||
Effect.map((answers) => ({ answers })),
|
||||
Effect.map((answers) => ({
|
||||
structured: { answers },
|
||||
content: [{ type: "text", text: toModelOutput(input.questions, answers) }],
|
||||
})),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -48,14 +48,6 @@ export const Plugin = {
|
||||
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ input, output }) => {
|
||||
if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime))
|
||||
return []
|
||||
return [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", data: output.content, mime: output.mime, name: input.path },
|
||||
]
|
||||
},
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
@@ -106,7 +98,9 @@ export const Plugin = {
|
||||
start: type === "directory" ? resolved : dirname(resolved),
|
||||
stop: root,
|
||||
})
|
||||
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter((file) => dirname(file) !== root)
|
||||
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
|
||||
(file) => dirname(file) !== root,
|
||||
)
|
||||
if (candidates.length === 0) return
|
||||
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
|
||||
}).pipe(
|
||||
@@ -132,6 +126,23 @@ export const Plugin = {
|
||||
: `Unable to read ${input.path}`
|
||||
return new ToolFailure({ message })
|
||||
}),
|
||||
Effect.map((structured) => ({
|
||||
structured,
|
||||
content:
|
||||
"encoding" in structured &&
|
||||
structured.encoding === "base64" &&
|
||||
SUPPORTED_IMAGE_MIMES.has(structured.mime)
|
||||
? [
|
||||
{ type: "text" as const, text: "Image read successfully" },
|
||||
{
|
||||
type: "file" as const,
|
||||
data: structured.content,
|
||||
mime: structured.mime,
|
||||
name: input.path,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
})),
|
||||
)
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -45,21 +45,17 @@ const StructuredOutput = Schema.Struct({
|
||||
timeout: Schema.Boolean.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const Output = Schema.Struct({
|
||||
...StructuredOutput.fields,
|
||||
output: Schema.String,
|
||||
status: Schema.Literals(["completed", "running"]).pipe(Schema.optional),
|
||||
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||
})
|
||||
type Result = typeof StructuredOutput.Type & {
|
||||
readonly output: string
|
||||
readonly status?: "completed" | "running"
|
||||
readonly warnings?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
type Output = typeof Output.Type
|
||||
|
||||
const modelOutput = (output: Output): string | undefined => {
|
||||
const modelOutput = (output: Result): string | undefined => {
|
||||
const warnings = output.warnings?.length
|
||||
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
|
||||
: ""
|
||||
if (output.status === "running")
|
||||
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}`
|
||||
if (output.status === "running") return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}`
|
||||
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
|
||||
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
|
||||
}
|
||||
@@ -144,20 +140,7 @@ export const Plugin = {
|
||||
[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. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
toStructuredOutput: ({ output }) => ({
|
||||
truncated: output.truncated,
|
||||
...(output.exit === undefined ? {} : { exit: output.exit }),
|
||||
...(output.shellID === undefined ? {} : { shellID: output.shellID }),
|
||||
...(output.timeout === undefined ? {} : { timeout: output.timeout }),
|
||||
}),
|
||||
toModelOutput: ({ output }) => {
|
||||
const parts: Content[] = [{ type: "text", text: output.output }]
|
||||
const model = modelOutput(output)
|
||||
if (model) parts.push({ type: "text", text: model })
|
||||
return parts
|
||||
},
|
||||
output: StructuredOutput,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = {
|
||||
@@ -247,9 +230,9 @@ export const Plugin = {
|
||||
}
|
||||
}
|
||||
|
||||
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
|
||||
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
|
||||
)
|
||||
const result = yield* runtime.job
|
||||
.block({ id: job.id, sessionID: context.sessionID })
|
||||
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
|
||||
return {
|
||||
@@ -260,14 +243,26 @@ export const Plugin = {
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}
|
||||
if (result?.info.status === "error") return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
|
||||
if (result?.info.status === "error")
|
||||
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
|
||||
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
|
||||
|
||||
return {
|
||||
...(yield* settleShell()),
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
|
||||
}).pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` })),
|
||||
Effect.map((result) => {
|
||||
const content: Content[] = [{ type: "text", text: result.output }]
|
||||
const model = modelOutput(result)
|
||||
if (model) content.push({ type: "text", text: model })
|
||||
return {
|
||||
structured: result,
|
||||
content,
|
||||
}
|
||||
}),
|
||||
),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -64,7 +64,6 @@ export const Plugin = {
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* skills.list()
|
||||
@@ -87,11 +86,15 @@ export const Plugin = {
|
||||
.toSorted()
|
||||
.slice(0, FILE_LIMIT)
|
||||
: []
|
||||
return {
|
||||
const structured = {
|
||||
name: skill.name,
|
||||
directory,
|
||||
output: toModelOutput(skill, files),
|
||||
}
|
||||
return {
|
||||
structured,
|
||||
content: [{ type: "text" as const, text: structured.output }],
|
||||
}
|
||||
}).pipe(Effect.mapError((error) => unableToLoad(input.name, error)))
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -92,13 +92,17 @@ export const Plugin = {
|
||||
)
|
||||
})
|
||||
|
||||
const output = (structured: typeof Output.Type) => ({
|
||||
structured,
|
||||
content: [{ type: "text" as const, text: structured.output }],
|
||||
})
|
||||
|
||||
yield* ctx.tool
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* runtime.session
|
||||
@@ -146,7 +150,7 @@ export const Plugin = {
|
||||
if (background) {
|
||||
yield* runtime.job.background(info.id)
|
||||
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
||||
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
|
||||
return output({ sessionID: child.id, status: "running", output: BACKGROUND_STARTED })
|
||||
}
|
||||
|
||||
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
|
||||
@@ -158,12 +162,16 @@ export const Plugin = {
|
||||
)
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
||||
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
|
||||
return output({ sessionID: child.id, status: "running", output: BACKGROUND_STARTED })
|
||||
}
|
||||
if (result?.info.status === "error")
|
||||
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
|
||||
if (result?.info.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" })
|
||||
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
|
||||
return output({
|
||||
sessionID: child.id,
|
||||
status: "completed",
|
||||
output: result?.info.output ?? NO_TEXT,
|
||||
})
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -33,7 +33,6 @@ export const Plugin = {
|
||||
"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: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
@@ -45,7 +44,11 @@ export const Plugin = {
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
yield* todos.update({ sessionID: context.sessionID, todos: input.todos })
|
||||
return { todos: input.todos }
|
||||
const structured = { todos: input.todos }
|
||||
return {
|
||||
structured,
|
||||
content: [{ type: "text" as const, text: toModelOutput(structured) }],
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -124,7 +124,6 @@ export const Plugin = {
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.try({
|
||||
@@ -170,7 +169,13 @@ export const Plugin = {
|
||||
format: input.format,
|
||||
output,
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
|
||||
}).pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` })),
|
||||
Effect.map((structured) => ({
|
||||
structured,
|
||||
content: [{ type: "text", text: structured.output }],
|
||||
})),
|
||||
),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -200,7 +200,6 @@ export const Plugin = {
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
||||
execute: (input, context) => {
|
||||
const provider = selectProvider(context.sessionID, config, config.provider)
|
||||
return Effect.gen(function* () {
|
||||
@@ -243,7 +242,13 @@ export const Plugin = {
|
||||
provider,
|
||||
text: text ?? NO_RESULTS,
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })))
|
||||
}).pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })),
|
||||
Effect.map((structured) => ({
|
||||
structured,
|
||||
content: [{ type: "text", text: structured.text }],
|
||||
})),
|
||||
)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -57,7 +57,6 @@ export const Plugin = {
|
||||
"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.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = {
|
||||
@@ -83,7 +82,13 @@ export const Plugin = {
|
||||
source,
|
||||
})
|
||||
return yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))),
|
||||
}).pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` })),
|
||||
Effect.map((structured) => ({
|
||||
structured,
|
||||
content: [{ type: "text", text: toModelOutput(structured) }],
|
||||
})),
|
||||
),
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("PluginV2", () => {
|
||||
description: "Plugin tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
execute: () => Effect.succeed({ structured: { ok: true }, content: [] }),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
@@ -146,7 +146,8 @@ describe("PluginV2", () => {
|
||||
description: "Echo",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })),
|
||||
execute: ({ text }) =>
|
||||
Effect.sync(() => executed.push({ text })).pipe(Effect.as({ structured: { text }, content: [] })),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -46,8 +46,7 @@ const make = (permission?: string) => {
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.succeed({ text }),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
||||
execute: ({ text }) => Effect.succeed({ structured: { text }, content: [{ type: "text" as const, text }] }),
|
||||
})
|
||||
return permission ? Tool.withPermission(tool, permission) : tool
|
||||
}
|
||||
@@ -244,7 +243,8 @@ describe("ToolRegistry", () => {
|
||||
description: "Context",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
|
||||
execute: (_, context) =>
|
||||
Effect.sync(() => contexts.push(context)).pipe(Effect.as({ structured: { ok: true }, content: [] })),
|
||||
}),
|
||||
})
|
||||
yield* executeTool(service, {
|
||||
@@ -276,7 +276,7 @@ describe("ToolRegistry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("enforces transformed codecs at execution and projection boundaries", () =>
|
||||
it.effect("validates structured output while preserving executor-provided model content", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const executed: string[] = []
|
||||
@@ -291,18 +291,23 @@ describe("ToolRegistry", () => {
|
||||
description: "Transform values",
|
||||
input: Schema.Struct({ value: Transformed }),
|
||||
output: Schema.Struct({ value: Transformed }),
|
||||
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
|
||||
execute: ({ value }) =>
|
||||
Effect.sync(() => executed.push(value)).pipe(
|
||||
Effect.as({ structured: { value }, content: [{ type: "text" as const, text: value }] }),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
yield* settleTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "transformed", name: "transformed", input: { value: true } },
|
||||
}),
|
||||
).toEqual({ type: "text", value: "true" })
|
||||
).toEqual({
|
||||
result: { type: "text", value: "yes" },
|
||||
output: { structured: { value: true }, content: [{ type: "text", text: "yes" }] },
|
||||
})
|
||||
expect(executed).toEqual(["yes"])
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
@@ -329,7 +334,7 @@ describe("ToolRegistry", () => {
|
||||
}),
|
||||
),
|
||||
}),
|
||||
execute: () => Effect.succeed({ value: "invalid" }),
|
||||
execute: () => Effect.succeed({ structured: { value: "invalid" }, content: [] }),
|
||||
}),
|
||||
})
|
||||
expect(
|
||||
@@ -411,8 +416,10 @@ describe("ToolRegistry", () => {
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) =>
|
||||
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as({ text })),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.as({ structured: { text }, content: [{ type: "text" as const, text }] }),
|
||||
),
|
||||
}),
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
|
||||
@@ -135,7 +135,6 @@ const echo = Layer.effectDiscard(
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
||||
execute: ({ text }, context) =>
|
||||
Effect.gen(function* () {
|
||||
authorizations.push(context)
|
||||
@@ -146,7 +145,7 @@ const echo = Layer.effectDiscard(
|
||||
yield* Deferred.succeed(toolExecutionsStarted, undefined)
|
||||
}
|
||||
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
|
||||
return { text }
|
||||
return { structured: { text }, content: [{ type: "text" as const, text }] }
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
|
||||
}),
|
||||
defect: Tool.make({
|
||||
@@ -161,7 +160,7 @@ const echo = Layer.effectDiscard(
|
||||
description: "Produce output that cannot be persisted",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Any,
|
||||
execute: () => Effect.succeed({ big: 1n }),
|
||||
execute: () => Effect.succeed({ structured: { big: 1n }, content: [] }),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
@@ -608,7 +607,7 @@ describe("SessionRunnerLLM", () => {
|
||||
execute: ({ query }, context) =>
|
||||
Effect.sync(() => {
|
||||
contexts.push(context)
|
||||
return { answer: query.toUpperCase() }
|
||||
return { structured: { answer: query.toUpperCase() }, content: [] }
|
||||
}),
|
||||
}),
|
||||
})
|
||||
@@ -2834,7 +2833,9 @@ describe("SessionRunnerLLM", () => {
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: (_, context) =>
|
||||
questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
|
||||
questions
|
||||
.ask({ sessionID: context.sessionID, questions: [] })
|
||||
.pipe(Effect.as({ structured: {}, content: [] }), Effect.orDie),
|
||||
}),
|
||||
})
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false })
|
||||
|
||||
@@ -31,6 +31,36 @@ Configuration supplied for the plugin is available as `ctx.options`.
|
||||
|
||||
Registrations are owned by the plugin scope. Closing the scope removes them automatically; a registration may also be removed early through `dispose`.
|
||||
|
||||
## Tools
|
||||
|
||||
Plugins register named tools through `ctx.tool`. The executor returns validated structured output and model-facing content together.
|
||||
|
||||
```ts
|
||||
import { define, Tool } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "greeting",
|
||||
effect: (ctx) =>
|
||||
ctx.tool
|
||||
.register({
|
||||
greet: Tool.make({
|
||||
description: "Greet someone",
|
||||
input: Schema.Struct({ name: Schema.String }),
|
||||
output: Schema.Struct({ message: Schema.String }),
|
||||
execute: ({ name }) => {
|
||||
const message = `Hello ${name}`
|
||||
return Effect.succeed({
|
||||
structured: { message },
|
||||
content: [{ type: "text", text: message }],
|
||||
})
|
||||
},
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
```
|
||||
|
||||
## Transform Hooks
|
||||
|
||||
Transform hooks contribute to stateful domains:
|
||||
|
||||
@@ -38,32 +38,19 @@ 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,
|
||||
> = {
|
||||
export type Result<Structured = unknown> = {
|
||||
readonly structured: Structured
|
||||
readonly content: ReadonlyArray<Content>
|
||||
}
|
||||
|
||||
type Config<Input extends SchemaType<any>, OutputSchema 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 output: OutputSchema
|
||||
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>
|
||||
}
|
||||
|
||||
export type DynamicOutput = {
|
||||
readonly structured: unknown
|
||||
readonly content: ReadonlyArray<Content>
|
||||
) => Effect.Effect<Result<Schema.Schema.Type<OutputSchema>>, ToolFailure>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +62,7 @@ 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<Result, ToolFailure>
|
||||
}
|
||||
|
||||
type Runtime = {
|
||||
@@ -86,23 +73,19 @@ type Runtime = {
|
||||
|
||||
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>, OutputSchema extends SchemaType<any>>(
|
||||
config: Config<Input, OutputSchema>,
|
||||
): Definition<Input, OutputSchema>
|
||||
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>, OutputSchema extends SchemaType<any>>(
|
||||
config: Config<Input, OutputSchema>,
|
||||
): Definition<Input, OutputSchema> {
|
||||
const tool = Object.freeze({}) as Definition<Input, OutputSchema>
|
||||
const definitions = new Map<string, ToolDefinition>()
|
||||
runtimes.set(tool, {
|
||||
definition: (name) => {
|
||||
@@ -112,7 +95,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
|
||||
@@ -123,14 +106,8 @@ function makeTyped<
|
||||
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 })),
|
||||
)
|
||||
}),
|
||||
Schema.encodeEffect(config.output)(output.structured).pipe(
|
||||
Effect.map((structured) => ToolOutput.make(structured, output.content.map(toModelContent))),
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
@@ -139,12 +116,6 @@ function makeTyped<
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.map(({ output, structured }) => ({
|
||||
structured,
|
||||
content:
|
||||
config.toModelOutput?.({ input, output }).map(toModelContent) ??
|
||||
(typeof output === "string" ? [{ type: "text" as const, text: output }] : []),
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -171,7 +142,7 @@ 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) }))),
|
||||
.pipe(Effect.map((output) => ToolOutput.make(output.structured, output.content.map(toModelContent)))),
|
||||
})
|
||||
return tool
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ it.live(
|
||||
description: "Embedded test tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
execute: () => Effect.succeed({ structured: { ok: true }, content: [] }),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
|
||||
+20
-18
@@ -7,30 +7,30 @@ V2 has one opaque type for locally executable tools:
|
||||
```ts
|
||||
type Definition<Input, Output>
|
||||
type AnyTool = Definition<any, any>
|
||||
type Result<Structured = unknown> = {
|
||||
readonly structured: Structured
|
||||
readonly content: ReadonlyArray<Tool.Content>
|
||||
}
|
||||
|
||||
const make: <
|
||||
Input extends Schema.Codec<any, any, never, never>,
|
||||
Output extends Schema.Codec<any, any, never, never>,
|
||||
OutputSchema extends Schema.Codec<any, any, never, never>,
|
||||
>(config: {
|
||||
readonly description: string
|
||||
readonly input: Input
|
||||
readonly output: Output
|
||||
readonly output: OutputSchema
|
||||
readonly execute: (
|
||||
input: Schema.Type<Input>,
|
||||
context: Tool.Context,
|
||||
) => Effect.Effect<Schema.Type<Output>, ToolFailure>
|
||||
readonly toModelOutput?: (input: {
|
||||
readonly input: Schema.Type<Input>
|
||||
readonly output: Output["Encoded"]
|
||||
}) => ReadonlyArray<Tool.Content>
|
||||
}) => Definition<Input, Output>
|
||||
) => Effect.Effect<Result<Schema.Type<OutputSchema>>, ToolFailure>
|
||||
}) => Definition<Input, OutputSchema>
|
||||
```
|
||||
|
||||
Application tools, built-ins, and statically authored plugin tools use this same constructor and execution contract.
|
||||
|
||||
`Tool.Definition` is opaque and has exactly one executor. Its schemas and executor are not public fields. The Tool module privately derives model definitions and interprets invocations for the registry; callers normally rely on `Tool.make` inference rather than naming the carrier type.
|
||||
|
||||
Input and output codecs are self-contained. Schema conversion cannot require services. Tool dependencies are acquired during construction and captured by `execute`.
|
||||
Input and structured-output codecs are self-contained. Schema conversion cannot require services. Tool dependencies are acquired during construction and captured by `execute`.
|
||||
|
||||
## Invocation Context
|
||||
|
||||
@@ -122,7 +122,11 @@ yield *
|
||||
metadata: { root: root.resource },
|
||||
})
|
||||
|
||||
return yield* filesystem.grep(input, root)
|
||||
const structured = yield* filesystem.grep(input, root)
|
||||
return {
|
||||
structured,
|
||||
content: [{ type: "text", text: formatMatches(structured) }],
|
||||
}
|
||||
}).pipe(/* translate expected typed errors to ToolFailure */),
|
||||
}),
|
||||
})
|
||||
@@ -139,22 +143,20 @@ The Location-scoped registry owns effective lookup and settlement. For each loca
|
||||
1. Resolves one effective named registration.
|
||||
2. Decodes provider input with the input codec.
|
||||
3. Invokes the tool with the runner-supplied context.
|
||||
4. Encodes the returned output with the output codec.
|
||||
5. Projects encoded output into model-facing content.
|
||||
4. Encodes the returned `structured` value with the output codec.
|
||||
5. Preserves the executor-provided model-facing `content`.
|
||||
6. Bounds the complete model-facing output.
|
||||
7. Returns the settlement and managed-output references to the runner, which persists them durably.
|
||||
|
||||
Invalid input never invokes the tool. Invalid output never produces a successful settlement.
|
||||
|
||||
`toModelOutput` is pure and total. When omitted, the encoded output remains structured output; an encoded string is also projected as text. Projection does not receive invocation identity because presentation depends only on validated input and output.
|
||||
Invalid input never invokes the tool. Invalid structured output never produces a successful settlement.
|
||||
|
||||
Step materialization captures the effective registration identity for each advertised name without retaining its handler. Settlement rejects the call as stale if that registration was removed or replaced, including when closing an overlay reveals the previously effective registration. The current handler is captured only after this check; removing or replacing its registration afterward does not affect the running invocation.
|
||||
|
||||
## Output Bounding
|
||||
|
||||
Tools return complete validated domain output. They do not truncate model-facing output or manage retention files.
|
||||
Tools return complete structured output and model-facing content together. Settlement validates the structured value; tools do not truncate model-facing output or manage retention files.
|
||||
|
||||
After projection, one generic settlement boundary bounds the channel actually sent to the provider. When content exists, only its textual parts are measured; structured metadata is retained unchanged without being double-counted, and native media remains unchanged under producer-owned limits. When content is empty, the structured output is measured. Oversized provider-facing text or structured output is retained in managed storage and replaced with a bounded text preview while structured metadata and media are preserved; if complete retention fails, settlement fails operationally rather than publishing lossy success. Managed paths never appear in `Tool.make`, tool output schemas, or projection callbacks solely for retention bookkeeping.
|
||||
One generic settlement boundary bounds the channel actually sent to the provider. When content exists, only its textual parts are measured; structured metadata is retained unchanged without being double-counted, and native media remains unchanged under producer-owned limits. When content is empty, the structured output is measured. Oversized provider-facing text or structured output is retained in managed storage and replaced with a bounded text preview while structured metadata and media are preserved; if complete retention fails, settlement fails operationally rather than publishing lossy success. Managed paths never appear in `Tool.make`, tool output schemas, or executors solely for retention bookkeeping.
|
||||
|
||||
Model-output bounding is not producer memory management. Processes and streaming sources may need separate capture or spooling limits before a tool result exists. Those limits must be modeled at the producer boundary and must not masquerade as model-output truncation. A producer cannot claim a complete retained output after it has already discarded bytes.
|
||||
|
||||
@@ -172,7 +174,7 @@ Leaf tools translate only errors they deliberately classify as recoverable. Broa
|
||||
## Laws
|
||||
|
||||
- **Single executor:** `Tool.make(config)` can invoke only `config.execute`.
|
||||
- **Codec boundary:** execution observes decoded input; projection observes encoded output.
|
||||
- **Codec boundary:** execution observes decoded input and returns decoded structured output; settlement validates and encodes that structured output.
|
||||
- **Durable identity:** invocation-owned records use the exact Session, agent, assistant message, and call IDs supplied by the runner.
|
||||
- **Scoped registration:** closing a Scope removes exactly its registration and reveals any prior active overlay.
|
||||
- **Captured execution:** registration changes cannot alter an invocation after effective lookup.
|
||||
|
||||
Reference in New Issue
Block a user