mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
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
|
## 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.
|
- `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.
|
- `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
|
## 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:
|
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
|
## 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`.
|
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.",
|
"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,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
|
||||||
execute: (input, context) => {
|
execute: (input, context) => {
|
||||||
const applied: Array<typeof Applied.Type> = []
|
const applied: Array<typeof Applied.Type> = []
|
||||||
const fail = (path: string) => {
|
const fail = (path: string) => {
|
||||||
@@ -184,7 +183,13 @@ export const Plugin = {
|
|||||||
{ discard: true },
|
{ discard: true },
|
||||||
)
|
)
|
||||||
return { applied, files: patchFiles }
|
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",
|
"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.",
|
"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,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ input, output }) => [
|
|
||||||
{ type: "text", text: toModelOutput(output, input.oldString, input.newString) },
|
|
||||||
],
|
|
||||||
execute: (input, context) => {
|
execute: (input, context) => {
|
||||||
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||||
effect.pipe(
|
effect.pipe(
|
||||||
@@ -204,7 +201,12 @@ export const Plugin = {
|
|||||||
],
|
],
|
||||||
replacements,
|
replacements,
|
||||||
} satisfies Output
|
} satisfies Output
|
||||||
})
|
}).pipe(
|
||||||
|
Effect.map((structured) => ({
|
||||||
|
structured,
|
||||||
|
content: [{ type: "text", text: toModelOutput(structured, input.oldString, input.newString) }],
|
||||||
|
})),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
"edit",
|
"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.",
|
"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,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ output }) => [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: toModelOutput(
|
|
||||||
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
@@ -102,6 +94,20 @@ export const Plugin = {
|
|||||||
? error
|
? error
|
||||||
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}` }),
|
: 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.",
|
"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,
|
input: Input,
|
||||||
output: Output,
|
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) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
@@ -133,6 +122,20 @@ export const Plugin = {
|
|||||||
? error
|
? error
|
||||||
: new ToolFailure({ message: `Unable to grep for ${input.pattern}` }),
|
: 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,
|
description,
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ input, output }) => [
|
|
||||||
{ type: "text", text: toModelOutput(input.questions, output.answers) },
|
|
||||||
],
|
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
permission
|
permission
|
||||||
.assert({
|
.assert({
|
||||||
@@ -77,7 +74,10 @@ export const Plugin = {
|
|||||||
})
|
})
|
||||||
.pipe(Effect.orDie),
|
.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.",
|
"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,
|
input: Input,
|
||||||
output: Output,
|
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) => {
|
execute: (input, context) => {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const source = {
|
const source = {
|
||||||
@@ -106,7 +98,9 @@ export const Plugin = {
|
|||||||
start: type === "directory" ? resolved : dirname(resolved),
|
start: type === "directory" ? resolved : dirname(resolved),
|
||||||
stop: root,
|
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
|
if (candidates.length === 0) return
|
||||||
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
|
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
|
||||||
}).pipe(
|
}).pipe(
|
||||||
@@ -132,6 +126,23 @@ export const Plugin = {
|
|||||||
: `Unable to read ${input.path}`
|
: `Unable to read ${input.path}`
|
||||||
return new ToolFailure({ message })
|
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),
|
timeout: Schema.Boolean.pipe(Schema.optional),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Output = Schema.Struct({
|
type Result = typeof StructuredOutput.Type & {
|
||||||
...StructuredOutput.fields,
|
readonly output: string
|
||||||
output: Schema.String,
|
readonly status?: "completed" | "running"
|
||||||
status: Schema.Literals(["completed", "running"]).pipe(Schema.optional),
|
readonly warnings?: ReadonlyArray<string>
|
||||||
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
|
}
|
||||||
})
|
|
||||||
|
|
||||||
type Output = typeof Output.Type
|
const modelOutput = (output: Result): string | undefined => {
|
||||||
|
|
||||||
const modelOutput = (output: Output): string | undefined => {
|
|
||||||
const warnings = output.warnings?.length
|
const warnings = output.warnings?.length
|
||||||
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
|
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
|
||||||
: ""
|
: ""
|
||||||
if (output.status === "running")
|
if (output.status === "running") return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}`
|
||||||
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}`
|
|
||||||
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
|
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}.`
|
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
|
||||||
}
|
}
|
||||||
@@ -144,20 +140,7 @@ export const Plugin = {
|
|||||||
[name]: Tool.make({
|
[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.`,
|
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,
|
input: Input,
|
||||||
output: Output,
|
output: StructuredOutput,
|
||||||
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
|
|
||||||
},
|
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const source = {
|
const source = {
|
||||||
@@ -247,9 +230,9 @@ export const Plugin = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
|
const result = yield* runtime.job
|
||||||
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
|
.block({ id: job.id, sessionID: context.sessionID })
|
||||||
)
|
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
|
||||||
if (result?.type === "backgrounded") {
|
if (result?.type === "backgrounded") {
|
||||||
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
|
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
|
||||||
return {
|
return {
|
||||||
@@ -260,14 +243,26 @@ export const Plugin = {
|
|||||||
...(warnings.length ? { warnings } : {}),
|
...(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"))
|
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(yield* settleShell()),
|
...(yield* settleShell()),
|
||||||
...(warnings.length ? { warnings } : {}),
|
...(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)
|
.pipe(Effect.orDie)
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export const Plugin = {
|
|||||||
description,
|
description,
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const current = yield* skills.list()
|
const current = yield* skills.list()
|
||||||
@@ -87,11 +86,15 @@ export const Plugin = {
|
|||||||
.toSorted()
|
.toSorted()
|
||||||
.slice(0, FILE_LIMIT)
|
.slice(0, FILE_LIMIT)
|
||||||
: []
|
: []
|
||||||
return {
|
const structured = {
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
directory,
|
directory,
|
||||||
output: toModelOutput(skill, files),
|
output: toModelOutput(skill, files),
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
structured,
|
||||||
|
content: [{ type: "text" as const, text: structured.output }],
|
||||||
|
}
|
||||||
}).pipe(Effect.mapError((error) => unableToLoad(input.name, error)))
|
}).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
|
yield* ctx.tool
|
||||||
.register({
|
.register({
|
||||||
[name]: Tool.make({
|
[name]: Tool.make({
|
||||||
description,
|
description,
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const parent = yield* runtime.session
|
const parent = yield* runtime.session
|
||||||
@@ -146,7 +150,7 @@ export const Plugin = {
|
|||||||
if (background) {
|
if (background) {
|
||||||
yield* runtime.job.background(info.id)
|
yield* runtime.job.background(info.id)
|
||||||
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
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(
|
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
|
||||||
@@ -158,12 +162,16 @@ export const Plugin = {
|
|||||||
)
|
)
|
||||||
if (result?.type === "backgrounded") {
|
if (result?.type === "backgrounded") {
|
||||||
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
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")
|
if (result?.info.status === "error")
|
||||||
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
|
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
|
||||||
if (result?.info.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" })
|
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.",
|
"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,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
@@ -45,7 +44,11 @@ export const Plugin = {
|
|||||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||||
})
|
})
|
||||||
yield* todos.update({ sessionID: context.sessionID, todos: input.todos })
|
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" }))),
|
}).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -124,7 +124,6 @@ export const Plugin = {
|
|||||||
description,
|
description,
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* Effect.try({
|
yield* Effect.try({
|
||||||
@@ -170,7 +169,13 @@ export const Plugin = {
|
|||||||
format: input.format,
|
format: input.format,
|
||||||
output,
|
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)
|
.pipe(Effect.orDie)
|
||||||
|
|||||||
@@ -200,7 +200,6 @@ export const Plugin = {
|
|||||||
description,
|
description,
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
|
||||||
execute: (input, context) => {
|
execute: (input, context) => {
|
||||||
const provider = selectProvider(context.sessionID, config, config.provider)
|
const provider = selectProvider(context.sessionID, config, config.provider)
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
@@ -243,7 +242,13 @@ export const Plugin = {
|
|||||||
provider,
|
provider,
|
||||||
text: text ?? NO_RESULTS,
|
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.",
|
"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,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const source = {
|
const source = {
|
||||||
@@ -83,7 +82,13 @@ export const Plugin = {
|
|||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
return yield* files.writeTextPreservingBom({ target, content: input.content })
|
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",
|
"edit",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ describe("PluginV2", () => {
|
|||||||
description: "Plugin tool",
|
description: "Plugin tool",
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||||
execute: () => Effect.succeed({ ok: true }),
|
execute: () => Effect.succeed({ structured: { ok: true }, content: [] }),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.pipe(Effect.orDie),
|
.pipe(Effect.orDie),
|
||||||
@@ -146,7 +146,8 @@ describe("PluginV2", () => {
|
|||||||
description: "Echo",
|
description: "Echo",
|
||||||
input: Schema.Struct({ text: Schema.String }),
|
input: Schema.Struct({ text: Schema.String }),
|
||||||
output: 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)
|
.pipe(Effect.orDie)
|
||||||
|
|||||||
@@ -46,8 +46,7 @@ const make = (permission?: string) => {
|
|||||||
description: "Echo text",
|
description: "Echo text",
|
||||||
input: Schema.Struct({ text: Schema.String }),
|
input: Schema.Struct({ text: Schema.String }),
|
||||||
output: Schema.Struct({ text: Schema.String }),
|
output: Schema.Struct({ text: Schema.String }),
|
||||||
execute: ({ text }) => Effect.succeed({ text }),
|
execute: ({ text }) => Effect.succeed({ structured: { text }, content: [{ type: "text" as const, text }] }),
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
|
||||||
})
|
})
|
||||||
return permission ? Tool.withPermission(tool, permission) : tool
|
return permission ? Tool.withPermission(tool, permission) : tool
|
||||||
}
|
}
|
||||||
@@ -244,7 +243,8 @@ describe("ToolRegistry", () => {
|
|||||||
description: "Context",
|
description: "Context",
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
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, {
|
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* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* ToolRegistry.Service
|
const service = yield* ToolRegistry.Service
|
||||||
const executed: string[] = []
|
const executed: string[] = []
|
||||||
@@ -291,18 +291,23 @@ describe("ToolRegistry", () => {
|
|||||||
description: "Transform values",
|
description: "Transform values",
|
||||||
input: Schema.Struct({ value: Transformed }),
|
input: Schema.Struct({ value: Transformed }),
|
||||||
output: Schema.Struct({ value: Transformed }),
|
output: Schema.Struct({ value: Transformed }),
|
||||||
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
|
execute: ({ value }) =>
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
|
Effect.sync(() => executed.push(value)).pipe(
|
||||||
|
Effect.as({ structured: { value }, content: [{ type: "text" as const, text: value }] }),
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
yield* executeTool(service, {
|
yield* settleTool(service, {
|
||||||
sessionID,
|
sessionID,
|
||||||
...identity,
|
...identity,
|
||||||
call: { type: "tool-call", id: "transformed", name: "transformed", input: { value: true } },
|
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(executed).toEqual(["yes"])
|
||||||
expect(
|
expect(
|
||||||
yield* executeTool(service, {
|
yield* executeTool(service, {
|
||||||
@@ -329,7 +334,7 @@ describe("ToolRegistry", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
execute: () => Effect.succeed({ value: "invalid" }),
|
execute: () => Effect.succeed({ structured: { value: "invalid" }, content: [] }),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
expect(
|
expect(
|
||||||
@@ -411,8 +416,10 @@ describe("ToolRegistry", () => {
|
|||||||
input: Schema.Struct({ text: Schema.String }),
|
input: Schema.Struct({ text: Schema.String }),
|
||||||
output: Schema.Struct({ text: Schema.String }),
|
output: Schema.Struct({ text: Schema.String }),
|
||||||
execute: ({ text }) =>
|
execute: ({ text }) =>
|
||||||
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as({ text })),
|
Deferred.succeed(started, undefined).pipe(
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
Effect.andThen(Deferred.await(release)),
|
||||||
|
Effect.as({ structured: { text }, content: [{ type: "text" as const, text }] }),
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.pipe(Scope.provide(scope))
|
.pipe(Scope.provide(scope))
|
||||||
|
|||||||
@@ -135,7 +135,6 @@ const echo = Layer.effectDiscard(
|
|||||||
description: "Echo text",
|
description: "Echo text",
|
||||||
input: Schema.Struct({ text: Schema.String }),
|
input: Schema.Struct({ text: Schema.String }),
|
||||||
output: Schema.Struct({ text: Schema.String }),
|
output: Schema.Struct({ text: Schema.String }),
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
|
||||||
execute: ({ text }, context) =>
|
execute: ({ text }, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
authorizations.push(context)
|
authorizations.push(context)
|
||||||
@@ -146,7 +145,7 @@ const echo = Layer.effectDiscard(
|
|||||||
yield* Deferred.succeed(toolExecutionsStarted, undefined)
|
yield* Deferred.succeed(toolExecutionsStarted, undefined)
|
||||||
}
|
}
|
||||||
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
|
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
|
||||||
return { text }
|
return { structured: { text }, content: [{ type: "text" as const, text }] }
|
||||||
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
|
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
|
||||||
}),
|
}),
|
||||||
defect: Tool.make({
|
defect: Tool.make({
|
||||||
@@ -161,7 +160,7 @@ const echo = Layer.effectDiscard(
|
|||||||
description: "Produce output that cannot be persisted",
|
description: "Produce output that cannot be persisted",
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.Any,
|
output: Schema.Any,
|
||||||
execute: () => Effect.succeed({ big: 1n }),
|
execute: () => Effect.succeed({ structured: { big: 1n }, content: [] }),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -608,7 +607,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
execute: ({ query }, context) =>
|
execute: ({ query }, context) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
contexts.push(context)
|
contexts.push(context)
|
||||||
return { answer: query.toUpperCase() }
|
return { structured: { answer: query.toUpperCase() }, content: [] }
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -2834,7 +2833,9 @@ describe("SessionRunnerLLM", () => {
|
|||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.Struct({}),
|
output: Schema.Struct({}),
|
||||||
execute: (_, context) =>
|
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 })
|
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`.
|
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
|
||||||
|
|
||||||
Transform hooks contribute to stateful domains:
|
Transform hooks contribute to stateful domains:
|
||||||
|
|||||||
@@ -38,32 +38,19 @@ export type Content =
|
|||||||
| { readonly type: "text"; readonly text: string }
|
| { readonly type: "text"; readonly text: string }
|
||||||
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
|
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
|
||||||
|
|
||||||
type Config<
|
export type Result<Structured = unknown> = {
|
||||||
Input extends SchemaType<any>,
|
readonly structured: Structured
|
||||||
Output extends SchemaType<any>,
|
readonly content: ReadonlyArray<Content>
|
||||||
Structured extends SchemaType<any> = Output,
|
}
|
||||||
> = {
|
|
||||||
|
type Config<Input extends SchemaType<any>, OutputSchema extends SchemaType<any>> = {
|
||||||
readonly description: string
|
readonly description: string
|
||||||
readonly input: Input
|
readonly input: Input
|
||||||
readonly output: Output
|
readonly output: OutputSchema
|
||||||
readonly structured?: Structured
|
|
||||||
readonly toStructuredOutput?: (input: {
|
|
||||||
readonly input: Schema.Schema.Type<Input>
|
|
||||||
readonly output: Output["Encoded"]
|
|
||||||
}) => Schema.Schema.Type<Structured>
|
|
||||||
readonly execute: (
|
readonly execute: (
|
||||||
input: Schema.Schema.Type<Input>,
|
input: Schema.Schema.Type<Input>,
|
||||||
context: Context,
|
context: Context,
|
||||||
) => Effect.Effect<Schema.Schema.Type<Output>, ToolFailure>
|
) => Effect.Effect<Result<Schema.Schema.Type<OutputSchema>>, 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>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,7 +62,7 @@ type DynamicConfig = {
|
|||||||
readonly description: string
|
readonly description: string
|
||||||
readonly jsonSchema: JsonSchema.JsonSchema
|
readonly jsonSchema: JsonSchema.JsonSchema
|
||||||
readonly outputSchema?: 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 = {
|
type Runtime = {
|
||||||
@@ -86,23 +73,19 @@ type Runtime = {
|
|||||||
|
|
||||||
const runtimes = new WeakMap<AnyTool, Runtime>()
|
const runtimes = new WeakMap<AnyTool, Runtime>()
|
||||||
|
|
||||||
export function make<
|
export function make<Input extends SchemaType<any>, OutputSchema extends SchemaType<any>>(
|
||||||
Input extends SchemaType<any>,
|
config: Config<Input, OutputSchema>,
|
||||||
Output extends SchemaType<any>,
|
): Definition<Input, OutputSchema>
|
||||||
Structured extends SchemaType<any> = Output,
|
|
||||||
>(config: Config<Input, Output, Structured>): Definition<Input, Structured>
|
|
||||||
export function make(config: DynamicConfig): AnyTool
|
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)
|
if ("jsonSchema" in config) return makeDynamic(config)
|
||||||
return makeTyped(config)
|
return makeTyped(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeTyped<
|
function makeTyped<Input extends SchemaType<any>, OutputSchema extends SchemaType<any>>(
|
||||||
Input extends SchemaType<any>,
|
config: Config<Input, OutputSchema>,
|
||||||
Output extends SchemaType<any>,
|
): Definition<Input, OutputSchema> {
|
||||||
Structured extends SchemaType<any> = Output,
|
const tool = Object.freeze({}) as Definition<Input, OutputSchema>
|
||||||
>(config: Config<Input, Output, Structured>): Definition<Input, Structured> {
|
|
||||||
const tool = Object.freeze({}) as Definition<Input, Structured>
|
|
||||||
const definitions = new Map<string, ToolDefinition>()
|
const definitions = new Map<string, ToolDefinition>()
|
||||||
runtimes.set(tool, {
|
runtimes.set(tool, {
|
||||||
definition: (name) => {
|
definition: (name) => {
|
||||||
@@ -112,7 +95,7 @@ function makeTyped<
|
|||||||
name,
|
name,
|
||||||
description: config.description,
|
description: config.description,
|
||||||
inputSchema: toJsonSchema(config.input),
|
inputSchema: toJsonSchema(config.input),
|
||||||
outputSchema: toJsonSchema(config.structured ?? config.output),
|
outputSchema: toJsonSchema(config.output),
|
||||||
})
|
})
|
||||||
definitions.set(name, definition)
|
definitions.set(name, definition)
|
||||||
return definition
|
return definition
|
||||||
@@ -123,14 +106,8 @@ function makeTyped<
|
|||||||
Effect.flatMap((input) =>
|
Effect.flatMap((input) =>
|
||||||
config.execute(input, context).pipe(
|
config.execute(input, context).pipe(
|
||||||
Effect.flatMap((output) =>
|
Effect.flatMap((output) =>
|
||||||
Schema.encodeEffect(config.output)(output).pipe(
|
Schema.encodeEffect(config.output)(output.structured).pipe(
|
||||||
Effect.flatMap((output) => {
|
Effect.map((structured) => ToolOutput.make(structured, output.content.map(toModelContent))),
|
||||||
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(
|
Effect.mapError(
|
||||||
(error) =>
|
(error) =>
|
||||||
new ToolFailure({
|
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) =>
|
settle: (call, context) =>
|
||||||
config
|
config
|
||||||
.execute(call.input, context)
|
.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
|
return tool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ it.live(
|
|||||||
description: "Embedded test tool",
|
description: "Embedded test tool",
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||||
execute: () => Effect.succeed({ ok: true }),
|
execute: () => Effect.succeed({ structured: { ok: true }, content: [] }),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.pipe(Effect.orDie),
|
.pipe(Effect.orDie),
|
||||||
|
|||||||
+20
-18
@@ -7,30 +7,30 @@ V2 has one opaque type for locally executable tools:
|
|||||||
```ts
|
```ts
|
||||||
type Definition<Input, Output>
|
type Definition<Input, Output>
|
||||||
type AnyTool = Definition<any, any>
|
type AnyTool = Definition<any, any>
|
||||||
|
type Result<Structured = unknown> = {
|
||||||
|
readonly structured: Structured
|
||||||
|
readonly content: ReadonlyArray<Tool.Content>
|
||||||
|
}
|
||||||
|
|
||||||
const make: <
|
const make: <
|
||||||
Input extends Schema.Codec<any, any, never, never>,
|
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: {
|
>(config: {
|
||||||
readonly description: string
|
readonly description: string
|
||||||
readonly input: Input
|
readonly input: Input
|
||||||
readonly output: Output
|
readonly output: OutputSchema
|
||||||
readonly execute: (
|
readonly execute: (
|
||||||
input: Schema.Type<Input>,
|
input: Schema.Type<Input>,
|
||||||
context: Tool.Context,
|
context: Tool.Context,
|
||||||
) => Effect.Effect<Schema.Type<Output>, ToolFailure>
|
) => Effect.Effect<Result<Schema.Type<OutputSchema>>, ToolFailure>
|
||||||
readonly toModelOutput?: (input: {
|
}) => Definition<Input, OutputSchema>
|
||||||
readonly input: Schema.Type<Input>
|
|
||||||
readonly output: Output["Encoded"]
|
|
||||||
}) => ReadonlyArray<Tool.Content>
|
|
||||||
}) => Definition<Input, Output>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Application tools, built-ins, and statically authored plugin tools use this same constructor and execution contract.
|
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.
|
`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
|
## Invocation Context
|
||||||
|
|
||||||
@@ -122,7 +122,11 @@ yield *
|
|||||||
metadata: { root: root.resource },
|
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 */),
|
}).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.
|
1. Resolves one effective named registration.
|
||||||
2. Decodes provider input with the input codec.
|
2. Decodes provider input with the input codec.
|
||||||
3. Invokes the tool with the runner-supplied context.
|
3. Invokes the tool with the runner-supplied context.
|
||||||
4. Encodes the returned output with the output codec.
|
4. Encodes the returned `structured` value with the output codec.
|
||||||
5. Projects encoded output into model-facing content.
|
5. Preserves the executor-provided model-facing `content`.
|
||||||
6. Bounds the complete model-facing output.
|
6. Bounds the complete model-facing output.
|
||||||
7. Returns the settlement and managed-output references to the runner, which persists them durably.
|
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.
|
Invalid input never invokes the tool. Invalid structured 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.
|
|
||||||
|
|
||||||
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.
|
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
|
## 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.
|
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
|
## Laws
|
||||||
|
|
||||||
- **Single executor:** `Tool.make(config)` can invoke only `config.execute`.
|
- **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.
|
- **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.
|
- **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.
|
- **Captured execution:** registration changes cannot alter an invocation after effective lookup.
|
||||||
|
|||||||
Reference in New Issue
Block a user