Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/core/src/tool/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ 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.

Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.

## 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:

Expand Down Expand Up @@ -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`.

Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/tool/apply-patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 6 additions & 4 deletions packages/core/src/tool/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down
22 changes: 14 additions & 8 deletions packages/core/src/tool/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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),
})),
),
},
],
})),
),
}),
})
Expand Down
25 changes: 14 additions & 11 deletions packages/core/src/tool/grep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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) },
})),
),
},
],
})),
),
}),
})
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/tool/question.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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) }],
})),
),
}),
})
Expand Down
29 changes: 20 additions & 9 deletions packages/core/src/tool/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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(
Expand All @@ -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,
},
]
: [],
})),
)
},
}),
Expand Down
55 changes: 25 additions & 30 deletions packages/core/src/tool/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 Output = typeof Output.Type
type Result = typeof StructuredOutput.Type & {
readonly output: string
readonly status?: "completed" | "running"
readonly warnings?: ReadonlyArray<string>
}

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}.`
}
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/tool/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)))
}),
}),
Expand Down
16 changes: 12 additions & 4 deletions packages/core/src/tool/subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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,
})
}),
}),
})
Expand Down
Loading
Loading