Compare commits

...

4 Commits

Author SHA1 Message Date
Kit Langton 53b213571f fix(tui): hide synthetic session context rows 2026-07-01 10:38:11 -04:00
Kit Langton 3841955fbc refactor(plugin): simplify tool result projection 2026-07-01 00:41:45 -04:00
Kit Langton 5735a14188 fix(core): format background shell completion notice 2026-06-30 23:51:13 -04:00
Kit Langton 3e583a3f5a feat(plugin): add tool result content API 2026-06-30 23:38:00 -04:00
27 changed files with 349 additions and 200 deletions
+1
View File
@@ -254,6 +254,7 @@ export const layer = Layer.effect(
agent: agent.id, agent: agent.id,
assistantMessageID, assistantMessageID,
call: event, call: event,
progress: (output) => publication.withPermit(publisher.progressTool(event.id, output)),
}), }),
).pipe( ).pipe(
Effect.flatMap((settlement) => Effect.flatMap((settlement) =>
@@ -43,11 +43,13 @@ type SettledOutput =
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] } | { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
| { readonly error: { readonly type: "unknown"; readonly message: string } } | { readonly error: { readonly type: "unknown"; readonly message: string } }
const outputState = (value: ToolOutput) => ({ structured: record(value.structured), content: value.content })
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => { const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } } if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } }
const settled = value ?? ToolOutput.fromResultValue(result) const settled = value ?? ToolOutput.fromResultValue(result)
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`) if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
return { structured: record(settled.structured), content: settled.content } return outputState(settled)
} }
/** Persist one provider turn without executing tools or starting a continuation turn. */ /** Persist one provider turn without executing tools or starting a continuation turn. */
@@ -236,6 +238,19 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(`Unknown tool call: ${callID}`) return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(`Unknown tool call: ${callID}`)
} }
const progressTool = Effect.fn("SessionRunner.progressTool")(function* (callID: string, output: ToolOutput) {
const tool = tools.get(callID)
if (!tool?.called) return yield* Effect.die(`Tool progress before call: ${callID}`)
if (tool.settled) return yield* Effect.void
yield* events.publish(SessionEvent.Tool.Progress, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID,
callID,
...outputState(output),
})
})
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* ( const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
event: LLMEvent, event: LLMEvent,
outputPaths: ReadonlyArray<string> = [], outputPaths: ReadonlyArray<string> = [],
@@ -419,5 +434,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
stepSettlement: () => stepSettlement, stepSettlement: () => stepSettlement,
startAssistant, startAssistant,
assistantMessageID: assistantMessageIDForTool, assistantMessageID: assistantMessageIDForTool,
progressTool,
} }
} }
+2 -2
View File
@@ -70,7 +70,6 @@ export const layer = Layer.effectDiscard(
"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) => {
@@ -183,7 +182,8 @@ export const layer = Layer.effectDiscard(
}).pipe(Effect.mapError(() => fail(change.path))), }).pipe(Effect.mapError(() => fail(change.path))),
{ discard: true }, { discard: true },
) )
return { applied, files: patchFiles } const output = { applied, files: patchFiles }
return Tool.result({ output, content: [{ type: "text", text: toModelOutput(output) }] })
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch")))) }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
}, },
}), }),
+5 -4
View File
@@ -101,9 +101,6 @@ export const layer = Layer.effectDiscard(
"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(
@@ -193,7 +190,7 @@ export const layer = Layer.effectDiscard(
content: joinBom(next.text, source.bom || next.bom), content: joinBom(next.text, source.bom || next.bom),
}), }),
) )
return { const output = {
files: [ files: [
{ {
file: result.resource, file: result.resource,
@@ -204,6 +201,10 @@ export const layer = Layer.effectDiscard(
], ],
replacements, replacements,
} satisfies Output } satisfies Output
return Tool.result({
output,
content: [{ type: "text", text: toModelOutput(output, input.oldString, input.newString) }],
})
}) })
}, },
}), }),
+13 -8
View File
@@ -47,14 +47,6 @@ export const layer = Layer.effectDiscard(
"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({
@@ -86,6 +78,19 @@ export const layer = Layer.effectDiscard(
}), }),
), ),
), ),
Effect.map((output) =>
Tool.result({
output,
content: [
{
type: "text",
text: toModelOutput(
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
),
},
],
}),
),
) )
}).pipe( }).pipe(
Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })), Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })),
+16 -11
View File
@@ -63,17 +63,6 @@ export const layer = Layer.effectDiscard(
"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({
@@ -120,6 +109,22 @@ export const layer = Layer.effectDiscard(
}), }),
), ),
), ),
Effect.map((output) =>
Tool.result({
output,
content: [
{
type: "text",
text: toModelOutput(
output.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
),
},
],
}),
),
) )
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to grep for ${input.pattern}` }))), }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to grep for ${input.pattern}` }))),
}), }),
+14 -10
View File
@@ -56,15 +56,18 @@ export const layer = Layer.effectDiscard(
jsonSchema: (tool.inputSchema as JsonSchema.JsonSchema | undefined) ?? { type: "object", properties: {} }, jsonSchema: (tool.inputSchema as JsonSchema.JsonSchema | undefined) ?? { type: "object", properties: {} },
execute: (input) => execute: (input) =>
Effect.gen(function* () { Effect.gen(function* () {
const result = yield* mcp.callTool({ server, name: tool.name, args: (input ?? {}) as Record<string, unknown> }).pipe( const result = yield* mcp
Effect.catchTags({ .callTool({ server, name: tool.name, args: (input ?? {}) as Record<string, unknown> })
"MCP.NotFoundError": (error) => new ToolFailure({ message: `MCP server "${error.server}" is not available` }), .pipe(
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }), Effect.catchTags({
}), "MCP.NotFoundError": (error) =>
) new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
}),
)
if (result.isError) if (result.isError)
return yield* new ToolFailure({ message: errorText(result.content) || "MCP tool returned an error" }) return yield* new ToolFailure({ message: errorText(result.content) || "MCP tool returned an error" })
return { structured: result.structured ?? {}, content: result.content.map(toContent) } return Tool.result({ output: result.structured ?? {}, content: result.content.map(toContent) })
}), }),
}) })
@@ -88,9 +91,10 @@ export const layer = Layer.effectDiscard(
) )
yield* reconcile.pipe(Effect.forkScoped) yield* reconcile.pipe(Effect.forkScoped)
yield* events yield* events.subscribe(McpEvent.ToolsChanged).pipe(
.subscribe(McpEvent.ToolsChanged) Stream.runForEach(() => reconcile),
.pipe(Stream.runForEach(() => reconcile), Effect.forkScoped({ startImmediately: true })) Effect.forkScoped({ startImmediately: true }),
)
}), }),
) )
+6 -4
View File
@@ -54,9 +54,6 @@ export const layer = Layer.effectDiscard(
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,12 @@ export const layer = Layer.effectDiscard(
}) })
.pipe(Effect.orDie), .pipe(Effect.orDie),
), ),
Effect.map((answers) => ({ answers })), Effect.map((answers) =>
Tool.result({
output: { answers },
content: [{ type: "text", text: toModelOutput(input.questions, answers) }],
}),
),
), ),
}), }),
}) })
+8 -9
View File
@@ -40,14 +40,6 @@ export const layer = Layer.effectDiscard(
"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 = {
@@ -82,9 +74,16 @@ export const layer = Layer.effectDiscard(
limit: input.limit, limit: input.limit,
}) })
if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) { if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
return yield* image const output = yield* image
.normalize(resource, { ...content, encoding: "base64" }) .normalize(resource, { ...content, encoding: "base64" })
.pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content))) .pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
return Tool.result({
output,
content: [
{ type: "text", text: "Image read successfully" },
{ type: "file", data: output.content, mime: output.mime, name: input.path },
],
})
} }
if ("encoding" in content && content.encoding === "base64") if ("encoding" in content && content.encoding === "base64")
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource })) return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
+11
View File
@@ -17,6 +17,7 @@ export type ExecuteInput = {
readonly agent: AgentV2.ID readonly agent: AgentV2.ID
readonly assistantMessageID: SessionMessage.ID readonly assistantMessageID: SessionMessage.ID
readonly call: ToolCall readonly call: ToolCall
readonly progress?: (output: ToolOutput) => Effect.Effect<void>
} }
export interface Interface { export interface Interface {
@@ -61,11 +62,21 @@ const registryLayer = Layer.effect(
} }
if (advertised && registration.identity !== advertised) if (advertised && registration.identity !== advertised)
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } } return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
const emitProgress = input.progress
const pending = yield* settle(registration.tool, input.call, { const pending = yield* settle(registration.tool, input.call, {
sessionID: input.sessionID, sessionID: input.sessionID,
agent: input.agent, agent: input.agent,
assistantMessageID: input.assistantMessageID, assistantMessageID: input.assistantMessageID,
toolCallID: input.call.id, toolCallID: input.call.id,
...(emitProgress
? {
progress: (output: ToolOutput) =>
resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output }).pipe(
Effect.flatMap((bounded) => emitProgress(bounded.output)),
Effect.ignore,
),
}
: {}),
}).pipe( }).pipe(
Effect.map((output) => ({ output })), Effect.map((output) => ({ output })),
Effect.catchTag("LLM.ToolFailure", (failure) => Effect.catchTag("LLM.ToolFailure", (failure) =>
+22 -38
View File
@@ -11,7 +11,7 @@ import { PluginRuntime } from "../plugin/runtime"
import { PositiveInt } from "../schema" import { PositiveInt } from "../schema"
import { SessionSchema } from "../session/schema" import { SessionSchema } from "../session/schema"
import { Shell } from "../shell" import { Shell } from "../shell"
import { Tool, type Content } from "./tool" import { Tool } from "./tool"
export const name = "shell" export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000 export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
@@ -42,24 +42,23 @@ const StructuredOutput = Schema.Struct({
shellID: Schema.String.pipe(Schema.optional), shellID: Schema.String.pipe(Schema.optional),
truncated: Schema.Boolean, truncated: Schema.Boolean,
timeout: Schema.Boolean.pipe(Schema.optional), timeout: Schema.Boolean.pipe(Schema.optional),
status: Schema.Literals(["completed", "running"]),
}) })
const Output = Schema.Struct({ const Output = StructuredOutput
...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 Output = typeof Output.Type
const modelOutput = (output: Output): string | undefined => { const modelOutput = (output: Output, warnings: ReadonlyArray<string> = []): string | undefined => {
if (output.status === "running") return undefined if (output.status === "running") return undefined
const warnings = output.warnings?.length const warningText = warnings.length ? `\n\nWarnings:\n${warnings.map((warning) => `- ${warning}`).join("\n")}` : ""
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}` if (output.timeout)
: "" return `${warningText.trimStart()}${warningText ? "\n\n" : ""}Command timed out before completion.`
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.` return `${warningText.trimStart()}${warningText ? "\n\n" : ""}Command exited with code ${output.exit}.`
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.` }
const content = (body: string, output: Output, warnings: ReadonlyArray<string> = []) => {
const status = modelOutput(output, warnings)
return [{ type: "text" as const, text: body }, ...(status ? [{ type: "text" as const, text: status }] : [])]
} }
/** /**
@@ -71,7 +70,7 @@ const modelOutput = (output: Output): string | undefined => {
// TODO: Replace token-based command-argument external-directory advisories with parser-based detection. // TODO: Replace token-based command-argument external-directory advisories with parser-based detection.
// TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows. // TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows.
// TODO: Add plugin shell.env environment augmentation once V2 plugin hooks exist. // TODO: Add plugin shell.env environment augmentation once V2 plugin hooks exist.
// TODO: Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired. // TODO: Stream shell progress checkpoints without persisting every stdout/stderr chunk.
// TODO: Persist job status and define restart recovery before exposing remote observation. // TODO: Persist job status and define restart recovery before exposing remote observation.
// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined. // TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it. // TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
@@ -126,7 +125,7 @@ export const Plugin = {
: "Command cancelled" : "Command cancelled"
return runtime.session.synthetic({ return runtime.session.synthetic({
sessionID, sessionID,
text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`, text: `Shell command ${state}.\n\nCommand:\n${command}\n\n${state === "completed" ? "Output" : "Details"}:\n${text}`,
}) })
}), }),
Effect.forkIn(scope, { startImmediately: true }), Effect.forkIn(scope, { startImmediately: true }),
@@ -139,19 +138,6 @@ export const Plugin = {
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: 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
},
execute: (input, context) => execute: (input, context) =>
Effect.gen(function* () { Effect.gen(function* () {
const source = { const source = {
@@ -217,13 +203,12 @@ export const Plugin = {
}) })
yield* runtime.job.background(info.id) yield* runtime.job.background(info.id)
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command) yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
return { const output = {
output: BACKGROUND_STARTED,
shellID: background.id, shellID: background.id,
truncated: false, truncated: false,
status: "running" as const, status: "running" as const,
...(warnings.length ? { warnings } : {}),
} }
return Tool.result({ output, content: content(BACKGROUND_STARTED, output, warnings) })
} }
const info = yield* shell.create({ const info = yield* shell.create({
@@ -236,26 +221,25 @@ export const Plugin = {
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
if (final.status === "timeout") { if (final.status === "timeout") {
return { const body = `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
const output = {
exit: final.exit, exit: final.exit,
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false, truncated: false,
timeout: true, timeout: true,
status: "completed" as const, status: "completed" as const,
...(warnings.length ? { warnings } : {}),
} }
return Tool.result({ output, content: content(body, output, warnings) })
} }
const truncated = page.size > page.cursor const truncated = page.size > page.cursor
const body = page.output || "(no output)" const body = page.output || "(no output)"
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : "" const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
return { const output = {
exit: final.exit, exit: final.exit,
output: `${body}${notice}`,
truncated, truncated,
status: "completed" as const, status: "completed" as const,
...(warnings.length ? { warnings } : {}),
} }
return Tool.result({ output, content: content(`${body}${notice}`, output, 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}` }))),
}), }),
}) })
+5 -7
View File
@@ -19,7 +19,6 @@ export const Input = Schema.Struct({
export const Output = Schema.Struct({ export const Output = Schema.Struct({
name: Schema.String, name: Schema.String,
directory: Schema.String, directory: Schema.String,
output: Schema.String,
}) })
export const description = [ export const description = [
@@ -64,7 +63,6 @@ export const layer = Layer.effectDiscard(
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 +85,11 @@ export const layer = Layer.effectDiscard(
.toSorted() .toSorted()
.slice(0, FILE_LIMIT) .slice(0, FILE_LIMIT)
: [] : []
return { const output = toModelOutput(skill, files)
name: skill.name, return Tool.result({
directory, output: { name: skill.name, directory },
output: toModelOutput(skill, files), content: [{ type: "text", text: output }],
} })
}).pipe(Effect.mapError((error) => unableToLoad(input.name, error))) }).pipe(Effect.mapError((error) => unableToLoad(input.name, error)))
}), }),
}), }),
+12 -5
View File
@@ -27,7 +27,6 @@ export const Input = Schema.Struct({
export const Output = Schema.Struct({ export const Output = Schema.Struct({
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
status: Schema.Literals(["completed", "running"]), status: Schema.Literals(["completed", "running"]),
output: Schema.String,
}) })
export const description = [ export const description = [
@@ -98,7 +97,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 parent = yield* runtime.session const parent = yield* runtime.session
@@ -146,7 +144,10 @@ 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 Tool.result({
output: { sessionID: child.id, status: "running" as const },
content: [{ type: "text", text: 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 +159,18 @@ 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 Tool.result({
output: { sessionID: child.id, status: "running" as const },
content: [{ type: "text", text: 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 Tool.result({
output: { sessionID: child.id, status: "completed" as const },
content: [{ type: "text", text: result?.info.output ?? NO_TEXT }],
})
}), }),
}), }),
}) })
+2 -2
View File
@@ -33,7 +33,6 @@ export const layer = Layer.effectDiscard(
"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,8 @@ export const layer = Layer.effectDiscard(
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 output = { todos: input.todos }
return Tool.result({ output, content: [{ type: "text", text: toModelOutput(output) }] })
}).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))), }).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))),
}), }),
}) })
+2 -4
View File
@@ -35,7 +35,6 @@ const Output = Schema.Struct({
url: Schema.String, url: Schema.String,
contentType: Schema.String, contentType: Schema.String,
format: Input.fields.format, format: Input.fields.format,
output: Schema.String,
}) })
type Format = (typeof Input.Type)["format"] type Format = (typeof Input.Type)["format"]
@@ -124,7 +123,6 @@ export const layer = Layer.effectDiscard(
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({
@@ -164,12 +162,12 @@ export const layer = Layer.effectDiscard(
try: () => convert(content, contentType, input.format), try: () => convert(content, contentType, input.format),
catch: (error) => error, catch: (error) => error,
}) })
return { const result = {
url: input.url, url: input.url,
contentType, contentType,
format: input.format, format: input.format,
output,
} }
return Tool.result({ output: result, content: [{ type: "text", text: output }] })
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))), }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
}), }),
}) })
+1 -6
View File
@@ -181,7 +181,6 @@ const callMcp = <F extends Schema.Struct.Fields>(
const Output = Schema.Struct({ const Output = Schema.Struct({
provider: Provider, provider: Provider,
text: Schema.String,
}) })
export const layer = Layer.effectDiscard( export const layer = Layer.effectDiscard(
@@ -197,7 +196,6 @@ export const layer = Layer.effectDiscard(
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* () {
@@ -236,10 +234,7 @@ export const layer = Layer.effectDiscard(
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}), ...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
}, },
) )
return { return Tool.result({ output: { provider }, content: [{ type: "text", text: text ?? NO_RESULTS }] })
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}` })))
}, },
}), }),
+2 -2
View File
@@ -57,7 +57,6 @@ export const layer = Layer.effectDiscard(
"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 = {
@@ -82,7 +81,8 @@ export const layer = Layer.effectDiscard(
agent: context.agent, agent: context.agent,
source, source,
}) })
return yield* files.writeTextPreservingBom({ target, content: input.content }) const output = yield* files.writeTextPreservingBom({ target, content: input.content })
return Tool.result({ output, content: [{ type: "text", text: toModelOutput(output) }] })
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))), }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))),
}), }),
"edit", "edit",
@@ -45,8 +45,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(Tool.result({ output: { text }, content: [{ type: "text", text }] })),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
}) })
return permission ? Tool.withPermission(tool, permission) : tool return permission ? Tool.withPermission(tool, permission) : tool
} }
@@ -237,13 +236,21 @@ describe("ToolRegistry", () => {
it.effect("passes complete invocation identity to the canonical handler", () => it.effect("passes complete invocation identity to the canonical handler", () =>
Effect.gen(function* () { Effect.gen(function* () {
const service = yield* ToolRegistry.Service const service = yield* ToolRegistry.Service
const contexts: Tool.Context[] = [] const contexts: Array<Omit<Tool.Context, "progress">> = []
yield* service.register({ yield* service.register({
context: Tool.make({ context: Tool.make({
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({
sessionID: context.sessionID,
agent: context.agent,
assistantMessageID: context.assistantMessageID,
toolCallID: context.toolCallID,
})
}).pipe(Effect.as({ ok: true })),
}), }),
}) })
yield* executeTool(service, { yield* executeTool(service, {
@@ -255,6 +262,62 @@ describe("ToolRegistry", () => {
}), }),
) )
it.effect("emits bounded progress snapshots from the tool context", () =>
Effect.gen(function* () {
bounds.length = 0
const service = yield* ToolRegistry.Service
const progress: unknown[] = []
yield* service.register({
progress: Tool.make({
description: "Progress",
input: Schema.Struct({}),
output: Schema.Struct({ text: Schema.String }),
execute: (_, context) =>
context
.progress({ output: { text: "loading" }, content: [{ type: "text", text: "loading" }] })
.pipe(Effect.as(Tool.result({ output: { text: "done" }, content: [{ type: "text", text: "done" }] }))),
}),
})
const settled = yield* settleTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-progress", name: "progress", input: {} },
progress: (output) => Effect.sync(() => progress.push(output)),
})
expect(progress).toEqual([{ structured: { text: "loading" }, content: [{ type: "text", text: "loading" }] }])
expect(bounds.map((input) => input.toolCallID)).toEqual(["call-progress", "call-progress"])
expect(settled).toMatchObject({
result: { type: "text", value: "done" },
output: { structured: { text: "done" } },
})
}),
)
it.effect("does not treat plain output-content objects as result envelopes", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
literal: Tool.make({
description: "Literal output object",
input: Schema.Struct({}),
output: Schema.Struct({ output: Schema.String, content: Schema.Array(Schema.String) }),
execute: () => Effect.succeed({ output: "value", content: ["not model content"] }),
}),
})
expect(
yield* settleTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-literal", name: "literal", input: {} },
}),
).toMatchObject({
result: { type: "json", value: { output: "value", content: ["not model content"] } },
output: { structured: { output: "value", content: ["not model content"] }, content: [] },
})
}),
)
it.effect("encodes output and applies generic settlement bounding", () => it.effect("encodes output and applies generic settlement bounding", () =>
Effect.gen(function* () { Effect.gen(function* () {
bounds.length = 0 bounds.length = 0
@@ -290,8 +353,10 @@ 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(Tool.result({ output: { value }, content: [{ type: "text", text: String(value === "yes") }] })),
),
}), }),
}) })
@@ -410,8 +475,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(Tool.result({ output: { text }, content: [{ type: "text", text }] })),
),
}), }),
}) })
.pipe(Scope.provide(scope)) .pipe(Scope.provide(scope))
+15 -6
View File
@@ -110,7 +110,7 @@ const recoveryModel = Model.make({
provider: "fake", provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }), route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }),
}) })
const authorizations: Tool.Context[] = [] const authorizations: Array<Omit<Tool.Context, "progress">> = []
const executions: string[] = [] const executions: string[] = []
const permission = Layer.succeed( const permission = Layer.succeed(
PermissionV2.Service, PermissionV2.Service,
@@ -132,10 +132,14 @@ 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({
sessionID: context.sessionID,
agent: context.agent,
assistantMessageID: context.assistantMessageID,
toolCallID: context.toolCallID,
})
executions.push(text) executions.push(text)
activeToolExecutions++ activeToolExecutions++
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions) maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
@@ -143,7 +147,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 Tool.result({ output: { text }, content: [{ type: "text", text }] })
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))), }).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
}), }),
defect: Tool.make({ defect: Tool.make({
@@ -580,7 +584,7 @@ describe("SessionRunnerLLM", () => {
yield* setup yield* setup
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
const contexts: Tool.Context[] = [] const contexts: Array<Omit<Tool.Context, "progress">> = []
yield* registry.register({ yield* registry.register({
location_context: Tool.make({ location_context: Tool.make({
description: "Read application context", description: "Read application context",
@@ -588,7 +592,12 @@ describe("SessionRunnerLLM", () => {
output: Schema.Struct({ answer: Schema.String }), output: Schema.Struct({ answer: Schema.String }),
execute: ({ query }, context) => execute: ({ query }, context) =>
Effect.sync(() => { Effect.sync(() => {
contexts.push(context) contexts.push({
sessionID: context.sessionID,
agent: context.agent,
assistantMessageID: context.assistantMessageID,
toolCallID: context.toolCallID,
})
return { answer: query.toUpperCase() } return { answer: query.toUpperCase() }
}), }),
}), }),
+1 -1
View File
@@ -435,7 +435,7 @@ test("keeps locked deferred parity TODOs visible", async () => {
"Replace token-based command-argument external-directory advisories with parser-based detection.", "Replace token-based command-argument external-directory advisories with parser-based detection.",
"Restore PowerShell and cmd-specific invocation/path handling on Windows.", "Restore PowerShell and cmd-specific invocation/path handling on Windows.",
"Add plugin shell.env environment augmentation once V2 plugin hooks exist.", "Add plugin shell.env environment augmentation once V2 plugin hooks exist.",
"Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.", "Stream shell progress checkpoints without persisting every stdout/stderr chunk.",
"Persist job status and define restart recovery before exposing remote observation.", "Persist job status and define restart recovery before exposing remote observation.",
"Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.", "Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.",
"Revisit binary output handling if stdout/stderr decoding is text-only.", "Revisit binary output handling if stdout/stderr decoding is text-only.",
+2 -1
View File
@@ -190,7 +190,8 @@ describe("SubagentTool", () => {
}, },
}) })
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText }) expect(settled.output?.structured).toMatchObject({ status: "completed" })
expect(settled.output?.content).toEqual([{ type: "text", text: childText }])
const child = yield* sessions.get(outputSessionID(settled.output?.structured)) const child = yield* sessions.get(outputSessionID(settled.output?.structured))
expect(child).toMatchObject({ expect(child).toMatchObject({
parentID: parent.id, parentID: parent.id,
+1 -1
View File
@@ -83,7 +83,7 @@ describe("WebFetchTool registration", () => {
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({ expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
result: { type: "text", value: "hello" }, result: { type: "text", value: "hello" },
output: { output: {
structured: { url, contentType: "text/plain", format: "text", output: "hello" }, structured: { url, contentType: "text/plain", format: "text" },
content: [{ type: "text", text: "hello" }], content: [{ type: "text", text: "hello" }],
}, },
}) })
+1 -1
View File
@@ -227,7 +227,7 @@ describe("WebSearchTool registration", () => {
expect(settled).toEqual({ expect(settled).toEqual({
result: { type: "text", value: "parallel results" }, result: { type: "text", value: "parallel results" },
output: { output: {
structured: { provider: "parallel", text: "parallel results" }, structured: { provider: "parallel" },
content: [{ type: "text", text: "parallel results" }], content: [{ type: "text", text: "parallel results" }],
}, },
}) })
+93 -66
View File
@@ -6,11 +6,12 @@ import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message" import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Effect, JsonSchema, Schema, type Scope } from "effect" import { Effect, JsonSchema, Schema, type Scope } from "effect"
export interface Context { export interface Context<Output = unknown> {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly agent: Agent.ID readonly agent: Agent.ID
readonly assistantMessageID: SessionMessage.ID readonly assistantMessageID: SessionMessage.ID
readonly toolCallID: string readonly toolCallID: string
readonly progress: (state: State<Output>) => Effect.Effect<void>
} }
export type SchemaType<A> = Schema.Codec<A, any> export type SchemaType<A> = Schema.Codec<A, any>
@@ -28,6 +29,33 @@ export type AnyTool = Definition<any, any>
export const Failure = ToolFailure export const Failure = ToolFailure
export type Failure = ToolFailure export type Failure = ToolFailure
const ResultTypeId = Symbol("@opencode-ai/plugin/Tool.Result")
export interface State<Output> {
readonly output: Output
readonly content?: ReadonlyArray<Content>
}
export interface Result<Output> extends State<Output> {
readonly [ResultTypeId]: true
}
class ResultValue<Output> implements Result<Output> {
readonly output: Output
readonly content?: ReadonlyArray<Content>
get [ResultTypeId]() {
return true as const
}
constructor(state: State<Output>) {
this.output = state.output
this.content = state.content
}
}
export const result = <Output>(state: State<Output>): Result<Output> => Object.freeze(new ResultValue(state))
export class RegistrationError extends Schema.TaggedErrorClass<RegistrationError>()("Tool.RegistrationError", { export class RegistrationError extends Schema.TaggedErrorClass<RegistrationError>()("Tool.RegistrationError", {
name: Schema.String, name: Schema.String,
message: Schema.String, message: Schema.String,
@@ -37,72 +65,69 @@ 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< type Config<Input extends SchemaType<any>, Output extends SchemaType<any>> = {
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
> = {
readonly description: string readonly description: string
readonly input: Input readonly input: Input
readonly output: Output readonly output: Output
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<Schema.Schema.Type<Output>>,
) => Effect.Effect<Schema.Schema.Type<Output>, ToolFailure> ) => Effect.Effect<Schema.Schema.Type<Output> | Result<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>
} }
/** /**
* Config for a tool whose input shape is a raw JSON Schema not known at compile * Config for a tool whose input shape is a raw JSON Schema not known at compile
* time (MCP servers, plugin manifests). Input is passed through as `unknown`; * time (MCP servers, plugin manifests). Input is passed through as `unknown`;
* `execute` returns the already-projected structured value and model content. * Return `Tool.result(...)` when the output needs explicit model content.
*/ */
type DynamicConfig = { 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<unknown, ToolFailure>
}
export interface RuntimeContext extends Omit<Context, "progress"> {
readonly progress?: (output: ToolOutput) => Effect.Effect<void, unknown>
} }
type Runtime = { type Runtime = {
readonly permission?: string readonly permission?: string
readonly definition: (name: string) => ToolDefinition readonly definition: (name: string) => ToolDefinition
readonly settle: (call: ToolCall, context: Context) => Effect.Effect<ToolOutput, ToolFailure> readonly settle: (call: ToolCall, context: RuntimeContext) => Effect.Effect<ToolOutput, ToolFailure>
} }
const runtimes = new WeakMap<AnyTool, Runtime>() const runtimes = new WeakMap<AnyTool, Runtime>()
export function make< export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
Input extends SchemaType<any>, config: Config<Input, Output>,
Output extends SchemaType<any>, ): Definition<Input, Output>
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>, Output extends SchemaType<any>>(
Input extends SchemaType<any>, config: Config<Input, Output>,
Output extends SchemaType<any>, ): Definition<Input, Output> {
Structured extends SchemaType<any> = Output, const tool = Object.freeze({}) as Definition<Input, Output>
>(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>()
const projectState = (state: State<Schema.Schema.Type<Output>>): Effect.Effect<ToolOutput, ToolFailure> =>
Schema.encodeEffect(config.output)(state.output).pipe(
Effect.map((output) => ToolOutput.make(output, contentOf(output, state.content))),
Effect.mapError(
(error) =>
new ToolFailure({
message: `Tool returned an invalid value for its output schema: ${error.message}`,
}),
),
)
const project = (value: Schema.Schema.Type<Output> | Result<Schema.Schema.Type<Output>>) =>
projectState(stateOf(value))
runtimes.set(tool, { runtimes.set(tool, {
definition: (name) => { definition: (name) => {
const cached = definitions.get(name) const cached = definitions.get(name)
@@ -111,7 +136,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
@@ -120,31 +145,15 @@ function makeTyped<
Schema.decodeUnknownEffect(config.input)(call.input).pipe( Schema.decodeUnknownEffect(config.input)(call.input).pipe(
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })), Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
Effect.flatMap((input) => Effect.flatMap((input) =>
config.execute(input, context).pipe( config
Effect.flatMap((output) => .execute(input, {
Schema.encodeEffect(config.output)(output).pipe( ...context,
Effect.flatMap((output) => { progress: (state) =>
if (!config.structured || !config.toStructuredOutput) context.progress
return Effect.succeed({ output, structured: output }) ? projectState(state).pipe(Effect.flatMap(context.progress), Effect.ignore)
return Schema.encodeEffect(config.structured)(config.toStructuredOutput({ input, output })).pipe( : Effect.void,
Effect.map((structured) => ({ output, structured })), })
) .pipe(Effect.flatMap(project)),
}),
Effect.mapError(
(error) =>
new ToolFailure({
message: `Tool returned an invalid value for its output schema: ${error.message}`,
}),
),
),
),
Effect.map(({ output, structured }) => ({
structured,
content:
config.toModelOutput?.({ input, output }).map(toModelContent) ??
(typeof output === "string" ? [{ type: "text" as const, text: output }] : []),
})),
),
), ),
), ),
}) })
@@ -154,6 +163,8 @@ function makeTyped<
function makeDynamic(config: DynamicConfig): AnyTool { function makeDynamic(config: DynamicConfig): AnyTool {
const tool = Object.freeze({}) as AnyTool const tool = Object.freeze({}) as AnyTool
const definitions = new Map<string, ToolDefinition>() const definitions = new Map<string, ToolDefinition>()
const projectState = (state: State<unknown>) => ToolOutput.make(state.output, contentOf(state.output, state.content))
const project = (value: unknown) => projectState(stateOf(value))
runtimes.set(tool, { runtimes.set(tool, {
definition: (name) => { definition: (name) => {
const cached = definitions.get(name) const cached = definitions.get(name)
@@ -169,12 +180,28 @@ function makeDynamic(config: DynamicConfig): AnyTool {
}, },
settle: (call, context) => settle: (call, context) =>
config config
.execute(call.input, context) .execute(call.input, {
.pipe(Effect.map((output) => ({ structured: output.structured, content: output.content.map(toModelContent) }))), ...context,
progress: (state) => context.progress?.(projectState(state)).pipe(Effect.ignore) ?? Effect.void,
})
.pipe(Effect.map(project)),
}) })
return tool return tool
} }
function stateOf<Output>(value: Output | Result<Output>): State<Output> {
if (isResult(value)) return value
return { output: value }
}
function isResult(value: unknown): value is Result<unknown> {
return typeof value === "object" && value !== null && ResultTypeId in value
}
function contentOf(output: unknown, content: ReadonlyArray<Content> | undefined) {
return content?.map(toModelContent) ?? (typeof output === "string" ? [{ type: "text" as const, text: output }] : [])
}
function toModelContent(part: Content) { function toModelContent(part: Content) {
if (part.type === "text") return { type: "text" as const, text: part.text } if (part.type === "text") return { type: "text" as const, text: part.text }
return { type: "file" as const, uri: `data:${part.mime};base64,${part.data}`, mime: part.mime, name: part.name } return { type: "file" as const, uri: `data:${part.mime};base64,${part.data}`, mime: part.mime, name: part.name }
@@ -199,7 +226,7 @@ export const withPermission = <Input extends SchemaType<any>, Output extends Sch
export const permission = (tool: AnyTool, name: string) => runtimeOf(tool).permission ?? name export const permission = (tool: AnyTool, name: string) => runtimeOf(tool).permission ?? name
export const definition = (name: string, tool: AnyTool) => runtimeOf(tool).definition(name) export const definition = (name: string, tool: AnyTool) => runtimeOf(tool).definition(name)
export const settle = (tool: AnyTool, call: ToolCall, context: Context) => runtimeOf(tool).settle(call, context) export const settle = (tool: AnyTool, call: ToolCall, context: RuntimeContext) => runtimeOf(tool).settle(call, context)
function runtimeOf(tool: AnyTool) { function runtimeOf(tool: AnyTool) {
const runtime = runtimes.get(tool) const runtime = runtimes.get(tool)
+2 -2
View File
@@ -1,2 +1,2 @@
export { Failure, RegistrationError, make } from "@opencode-ai/plugin/v2/effect/tool" export { Failure, RegistrationError, make, result } from "@opencode-ai/plugin/v2/effect/tool"
export type { AnyTool, Content, Context, Definition } from "@opencode-ai/plugin/v2/effect/tool" export type { AnyTool, Content, Context, Definition, Result, State } from "@opencode-ai/plugin/v2/effect/tool"
+1 -1
View File
@@ -118,7 +118,6 @@ export function createSessionRows(sessionID: Accessor<string>) {
const subscriptions = [ const subscriptions = [
data.on("session.next.prompted", message), data.on("session.next.prompted", message),
data.on("session.next.context.updated", message), data.on("session.next.context.updated", message),
data.on("session.next.synthetic", message),
data.on("session.next.shell.started", message), data.on("session.next.shell.started", message),
data.on("session.next.agent.switched", message), data.on("session.next.agent.switched", message),
data.on("session.next.model.switched", message), data.on("session.next.model.switched", message),
@@ -158,6 +157,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
export function reduceSessionRows(messages: SessionMessage[]) { export function reduceSessionRows(messages: SessionMessage[]) {
return messages.reduce<SessionRow[]>((rows, message) => { return messages.reduce<SessionRow[]>((rows, message) => {
if (message.type === "synthetic") return rows
if (message.type !== "assistant") { if (message.type !== "assistant") {
completePrevious(rows) completePrevious(rows)
rows.push({ type: "message", messageID: message.id }) rows.push({ type: "message", messageID: message.id })
@@ -62,6 +62,25 @@ test("keeps non-exploration tools as individual part rows", () => {
]) ])
}) })
test("skips synthetic context messages", () => {
const messages: SessionMessage[] = [
{ type: "user", id: "user-1", text: "Run background shell", time: { created: 0 } },
assistant("assistant-1", [{ type: "tool", id: "shell-1", name: "shell", state: pending(), time: { created: 1 } }]),
{
type: "synthetic",
id: "synthetic-1",
sessionID: "ses_1",
text: "Shell command completed.\n\nOutput:\nok",
time: { created: 2 },
},
]
expect(reduceSessionRows(messages)).toEqual([
{ type: "message", messageID: "user-1" },
{ type: "part", ref: { messageID: "assistant-1", partID: "shell-1" } },
])
})
test("groups across empty assistant reasoning parts", () => { test("groups across empty assistant reasoning parts", () => {
const messages: SessionMessage[] = [ const messages: SessionMessage[] = [
assistant("assistant-1", [ assistant("assistant-1", [