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,
assistantMessageID,
call: event,
progress: (output) => publication.withPermit(publisher.progressTool(event.id, output)),
}),
).pipe(
Effect.flatMap((settlement) =>
@@ -43,11 +43,13 @@ type SettledOutput =
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
| { 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 => {
if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } }
const settled = value ?? ToolOutput.fromResultValue(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. */
@@ -236,6 +238,19 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
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* (
event: LLMEvent,
outputPaths: ReadonlyArray<string> = [],
@@ -419,5 +434,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
stepSettlement: () => stepSettlement,
startAssistant,
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.",
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const fail = (path: string) => {
@@ -183,7 +182,8 @@ export const layer = Layer.effectDiscard(
}).pipe(Effect.mapError(() => fail(change.path))),
{ 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"))))
},
}),
+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.",
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(
@@ -193,7 +190,7 @@ export const layer = Layer.effectDiscard(
content: joinBom(next.text, source.bom || next.bom),
}),
)
return {
const output = {
files: [
{
file: result.resource,
@@ -204,6 +201,10 @@ export const layer = Layer.effectDiscard(
],
replacements,
} 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.",
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({
@@ -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(
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.",
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({
@@ -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}` }))),
}),
+14 -10
View File
@@ -56,15 +56,18 @@ export const layer = Layer.effectDiscard(
jsonSchema: (tool.inputSchema as JsonSchema.JsonSchema | undefined) ?? { type: "object", properties: {} },
execute: (input) =>
Effect.gen(function* () {
const result = yield* mcp.callTool({ server, name: tool.name, args: (input ?? {}) as Record<string, unknown> }).pipe(
Effect.catchTags({
"MCP.NotFoundError": (error) => new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
}),
)
const result = yield* mcp
.callTool({ server, name: tool.name, args: (input ?? {}) as Record<string, unknown> })
.pipe(
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)
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* events
.subscribe(McpEvent.ToolsChanged)
.pipe(Stream.runForEach(() => reconcile), Effect.forkScoped({ startImmediately: true }))
yield* events.subscribe(McpEvent.ToolsChanged).pipe(
Stream.runForEach(() => reconcile),
Effect.forkScoped({ startImmediately: true }),
)
}),
)
+6 -4
View File
@@ -54,9 +54,6 @@ export const layer = Layer.effectDiscard(
description,
input: Input,
output: Output,
toModelOutput: ({ input, output }) => [
{ type: "text", text: toModelOutput(input.questions, output.answers) },
],
execute: (input, context) =>
permission
.assert({
@@ -77,7 +74,12 @@ export const layer = Layer.effectDiscard(
})
.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.",
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 = {
@@ -82,9 +74,16 @@ export const layer = Layer.effectDiscard(
limit: input.limit,
})
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" })
.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")
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
+11
View File
@@ -17,6 +17,7 @@ export type ExecuteInput = {
readonly agent: AgentV2.ID
readonly assistantMessageID: SessionMessage.ID
readonly call: ToolCall
readonly progress?: (output: ToolOutput) => Effect.Effect<void>
}
export interface Interface {
@@ -61,11 +62,21 @@ const registryLayer = Layer.effect(
}
if (advertised && registration.identity !== advertised)
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, {
sessionID: input.sessionID,
agent: input.agent,
assistantMessageID: input.assistantMessageID,
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(
Effect.map((output) => ({ output })),
Effect.catchTag("LLM.ToolFailure", (failure) =>
+22 -38
View File
@@ -11,7 +11,7 @@ import { PluginRuntime } from "../plugin/runtime"
import { PositiveInt } from "../schema"
import { SessionSchema } from "../session/schema"
import { Shell } from "../shell"
import { Tool, type Content } from "./tool"
import { Tool } from "./tool"
export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
@@ -42,24 +42,23 @@ const StructuredOutput = Schema.Struct({
shellID: Schema.String.pipe(Schema.optional),
truncated: Schema.Boolean,
timeout: Schema.Boolean.pipe(Schema.optional),
status: Schema.Literals(["completed", "running"]),
})
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),
})
const Output = StructuredOutput
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
const warnings = output.warnings?.length
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
: ""
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}.`
const warningText = warnings.length ? `\n\nWarnings:\n${warnings.map((warning) => `- ${warning}`).join("\n")}` : ""
if (output.timeout)
return `${warningText.trimStart()}${warningText ? "\n\n" : ""}Command timed out before completion.`
return `${warningText.trimStart()}${warningText ? "\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: Restore PowerShell and cmd-specific invocation/path handling on Windows.
// 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: 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.
@@ -126,7 +125,7 @@ export const Plugin = {
: "Command cancelled"
return runtime.session.synthetic({
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 }),
@@ -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.`,
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
},
execute: (input, context) =>
Effect.gen(function* () {
const source = {
@@ -217,13 +203,12 @@ export const Plugin = {
})
yield* runtime.job.background(info.id)
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
return {
output: BACKGROUND_STARTED,
const output = {
shellID: background.id,
truncated: false,
status: "running" as const,
...(warnings.length ? { warnings } : {}),
}
return Tool.result({ output, content: content(BACKGROUND_STARTED, output, warnings) })
}
const info = yield* shell.create({
@@ -236,26 +221,25 @@ export const Plugin = {
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
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,
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false,
timeout: true,
status: "completed" as const,
...(warnings.length ? { warnings } : {}),
}
return Tool.result({ output, content: content(body, output, warnings) })
}
const truncated = page.size > page.cursor
const body = page.output || "(no output)"
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
return {
const output = {
exit: final.exit,
output: `${body}${notice}`,
truncated,
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}` }))),
}),
})
+5 -7
View File
@@ -19,7 +19,6 @@ export const Input = Schema.Struct({
export const Output = Schema.Struct({
name: Schema.String,
directory: Schema.String,
output: Schema.String,
})
export const description = [
@@ -64,7 +63,6 @@ export const layer = Layer.effectDiscard(
description,
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
const current = yield* skills.list()
@@ -87,11 +85,11 @@ export const layer = Layer.effectDiscard(
.toSorted()
.slice(0, FILE_LIMIT)
: []
return {
name: skill.name,
directory,
output: toModelOutput(skill, files),
}
const output = toModelOutput(skill, files)
return Tool.result({
output: { name: skill.name, directory },
content: [{ type: "text", text: output }],
})
}).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({
sessionID: SessionSchema.ID,
status: Schema.Literals(["completed", "running"]),
output: Schema.String,
})
export const description = [
@@ -98,7 +97,6 @@ export const Plugin = {
description,
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
const parent = yield* runtime.session
@@ -146,7 +144,10 @@ 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 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(
@@ -158,12 +159,18 @@ 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 Tool.result({
output: { sessionID: child.id, status: "running" as const },
content: [{ type: "text", text: 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 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.",
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
@@ -45,7 +44,8 @@ export const layer = Layer.effectDiscard(
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
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" }))),
}),
})
+2 -4
View File
@@ -35,7 +35,6 @@ const Output = Schema.Struct({
url: Schema.String,
contentType: Schema.String,
format: Input.fields.format,
output: Schema.String,
})
type Format = (typeof Input.Type)["format"]
@@ -124,7 +123,6 @@ export const layer = Layer.effectDiscard(
description,
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
yield* Effect.try({
@@ -164,12 +162,12 @@ export const layer = Layer.effectDiscard(
try: () => convert(content, contentType, input.format),
catch: (error) => error,
})
return {
const result = {
url: input.url,
contentType,
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}` }))),
}),
})
+1 -6
View File
@@ -181,7 +181,6 @@ const callMcp = <F extends Schema.Struct.Fields>(
const Output = Schema.Struct({
provider: Provider,
text: Schema.String,
})
export const layer = Layer.effectDiscard(
@@ -197,7 +196,6 @@ export const layer = Layer.effectDiscard(
description,
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: (input, context) => {
const provider = selectProvider(context.sessionID, config, config.provider)
return Effect.gen(function* () {
@@ -236,10 +234,7 @@ export const layer = Layer.effectDiscard(
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
},
)
return {
provider,
text: text ?? NO_RESULTS,
}
return Tool.result({ output: { provider }, content: [{ type: "text", text: text ?? NO_RESULTS }] })
}).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.",
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) =>
Effect.gen(function* () {
const source = {
@@ -82,7 +81,8 @@ export const layer = Layer.effectDiscard(
agent: context.agent,
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}` }))),
}),
"edit",
@@ -45,8 +45,7 @@ const make = (permission?: string) => {
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ text }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: ({ text }) => Effect.succeed(Tool.result({ output: { text }, content: [{ type: "text", text }] })),
})
return permission ? Tool.withPermission(tool, permission) : tool
}
@@ -237,13 +236,21 @@ describe("ToolRegistry", () => {
it.effect("passes complete invocation identity to the canonical handler", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
const contexts: Array<Omit<Tool.Context, "progress">> = []
yield* service.register({
context: Tool.make({
description: "Context",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
execute: (_, context) =>
Effect.sync(() => {
contexts.push({
sessionID: context.sessionID,
agent: context.agent,
assistantMessageID: context.assistantMessageID,
toolCallID: context.toolCallID,
})
}).pipe(Effect.as({ ok: true })),
}),
})
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", () =>
Effect.gen(function* () {
bounds.length = 0
@@ -290,8 +353,10 @@ describe("ToolRegistry", () => {
description: "Transform values",
input: Schema.Struct({ value: Transformed }),
output: Schema.Struct({ value: Transformed }),
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
execute: ({ value }) =>
Effect.sync(() => executed.push(value)).pipe(
Effect.as(Tool.result({ output: { value }, content: [{ type: "text", text: String(value === "yes") }] })),
),
}),
})
@@ -410,8 +475,10 @@ describe("ToolRegistry", () => {
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) =>
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as({ text })),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.as(Tool.result({ output: { text }, content: [{ type: "text", text }] })),
),
}),
})
.pipe(Scope.provide(scope))
+15 -6
View File
@@ -110,7 +110,7 @@ const recoveryModel = Model.make({
provider: "fake",
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 permission = Layer.succeed(
PermissionV2.Service,
@@ -132,10 +132,14 @@ const echo = Layer.effectDiscard(
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: ({ text }, context) =>
Effect.gen(function* () {
authorizations.push(context)
authorizations.push({
sessionID: context.sessionID,
agent: context.agent,
assistantMessageID: context.assistantMessageID,
toolCallID: context.toolCallID,
})
executions.push(text)
activeToolExecutions++
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
@@ -143,7 +147,7 @@ const echo = Layer.effectDiscard(
yield* Deferred.succeed(toolExecutionsStarted, undefined)
}
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
return { text }
return Tool.result({ output: { text }, content: [{ type: "text", text }] })
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
}),
defect: Tool.make({
@@ -580,7 +584,7 @@ describe("SessionRunnerLLM", () => {
yield* setup
const registry = yield* ToolRegistry.Service
const session = yield* SessionV2.Service
const contexts: Tool.Context[] = []
const contexts: Array<Omit<Tool.Context, "progress">> = []
yield* registry.register({
location_context: Tool.make({
description: "Read application context",
@@ -588,7 +592,12 @@ describe("SessionRunnerLLM", () => {
output: Schema.Struct({ answer: Schema.String }),
execute: ({ query }, context) =>
Effect.sync(() => {
contexts.push(context)
contexts.push({
sessionID: context.sessionID,
agent: context.agent,
assistantMessageID: context.assistantMessageID,
toolCallID: context.toolCallID,
})
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.",
"Restore PowerShell and cmd-specific invocation/path handling on Windows.",
"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.",
"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.",
+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))
expect(child).toMatchObject({
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({
result: { type: "text", value: "hello" },
output: {
structured: { url, contentType: "text/plain", format: "text", output: "hello" },
structured: { url, contentType: "text/plain", format: "text" },
content: [{ type: "text", text: "hello" }],
},
})
+1 -1
View File
@@ -227,7 +227,7 @@ describe("WebSearchTool registration", () => {
expect(settled).toEqual({
result: { type: "text", value: "parallel results" },
output: {
structured: { provider: "parallel", text: "parallel results" },
structured: { provider: "parallel" },
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 { Effect, JsonSchema, Schema, type Scope } from "effect"
export interface Context {
export interface Context<Output = unknown> {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly assistantMessageID: SessionMessage.ID
readonly toolCallID: string
readonly progress: (state: State<Output>) => Effect.Effect<void>
}
export type SchemaType<A> = Schema.Codec<A, any>
@@ -28,6 +29,33 @@ export type AnyTool = Definition<any, any>
export const 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", {
name: Schema.String,
message: Schema.String,
@@ -37,72 +65,69 @@ export type Content =
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
type Config<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
> = {
type Config<Input extends SchemaType<any>, Output extends SchemaType<any>> = {
readonly description: string
readonly input: Input
readonly output: Output
readonly structured?: Structured
readonly toStructuredOutput?: (input: {
readonly input: Schema.Schema.Type<Input>
readonly output: Output["Encoded"]
}) => Schema.Schema.Type<Structured>
readonly execute: (
input: Schema.Schema.Type<Input>,
context: Context,
) => Effect.Effect<Schema.Schema.Type<Output>, ToolFailure>
readonly toModelOutput?: (input: {
readonly input: Schema.Schema.Type<Input>
readonly output: Output["Encoded"]
}) => ReadonlyArray<Content>
}
export type DynamicOutput = {
readonly structured: unknown
readonly content: ReadonlyArray<Content>
context: Context<Schema.Schema.Type<Output>>,
) => Effect.Effect<Schema.Schema.Type<Output> | Result<Schema.Schema.Type<Output>>, ToolFailure>
}
/**
* 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`;
* `execute` returns the already-projected structured value and model content.
* Return `Tool.result(...)` when the output needs explicit model content.
*/
type DynamicConfig = {
readonly description: string
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
readonly execute: (input: unknown, context: Context) => Effect.Effect<DynamicOutput, ToolFailure>
readonly execute: (input: unknown, context: Context) => Effect.Effect<unknown, ToolFailure>
}
export interface RuntimeContext extends Omit<Context, "progress"> {
readonly progress?: (output: ToolOutput) => Effect.Effect<void, unknown>
}
type Runtime = {
readonly permission?: string
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>()
export function make<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
>(config: Config<Input, Output, Structured>): Definition<Input, Structured>
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
config: Config<Input, Output>,
): Definition<Input, Output>
export function make(config: DynamicConfig): AnyTool
export function make(config: Config<any, any, any> | DynamicConfig): AnyTool {
export function make(config: Config<any, any> | DynamicConfig): AnyTool {
if ("jsonSchema" in config) return makeDynamic(config)
return makeTyped(config)
}
function makeTyped<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
>(config: Config<Input, Output, Structured>): Definition<Input, Structured> {
const tool = Object.freeze({}) as Definition<Input, Structured>
function makeTyped<Input extends SchemaType<any>, Output extends SchemaType<any>>(
config: Config<Input, Output>,
): Definition<Input, Output> {
const tool = Object.freeze({}) as Definition<Input, Output>
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, {
definition: (name) => {
const cached = definitions.get(name)
@@ -111,7 +136,7 @@ function makeTyped<
name,
description: config.description,
inputSchema: toJsonSchema(config.input),
outputSchema: toJsonSchema(config.structured ?? config.output),
outputSchema: toJsonSchema(config.output),
})
definitions.set(name, definition)
return definition
@@ -120,31 +145,15 @@ function makeTyped<
Schema.decodeUnknownEffect(config.input)(call.input).pipe(
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
Effect.flatMap((input) =>
config.execute(input, context).pipe(
Effect.flatMap((output) =>
Schema.encodeEffect(config.output)(output).pipe(
Effect.flatMap((output) => {
if (!config.structured || !config.toStructuredOutput)
return Effect.succeed({ output, structured: output })
return Schema.encodeEffect(config.structured)(config.toStructuredOutput({ input, output })).pipe(
Effect.map((structured) => ({ output, structured })),
)
}),
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 }] : []),
})),
),
config
.execute(input, {
...context,
progress: (state) =>
context.progress
? projectState(state).pipe(Effect.flatMap(context.progress), Effect.ignore)
: Effect.void,
})
.pipe(Effect.flatMap(project)),
),
),
})
@@ -154,6 +163,8 @@ function makeTyped<
function makeDynamic(config: DynamicConfig): AnyTool {
const tool = Object.freeze({}) as AnyTool
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, {
definition: (name) => {
const cached = definitions.get(name)
@@ -169,12 +180,28 @@ function makeDynamic(config: DynamicConfig): AnyTool {
},
settle: (call, context) =>
config
.execute(call.input, context)
.pipe(Effect.map((output) => ({ structured: output.structured, content: output.content.map(toModelContent) }))),
.execute(call.input, {
...context,
progress: (state) => context.progress?.(projectState(state)).pipe(Effect.ignore) ?? Effect.void,
})
.pipe(Effect.map(project)),
})
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) {
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 }
@@ -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 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) {
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 type { AnyTool, Content, Context, Definition } 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, 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 = [
data.on("session.next.prompted", 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.agent.switched", message),
data.on("session.next.model.switched", message),
@@ -158,6 +157,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
export function reduceSessionRows(messages: SessionMessage[]) {
return messages.reduce<SessionRow[]>((rows, message) => {
if (message.type === "synthetic") return rows
if (message.type !== "assistant") {
completePrevious(rows)
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", () => {
const messages: SessionMessage[] = [
assistant("assistant-1", [