diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 6d0e768dbc..0bf40b568f 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -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) => diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 33652a618c..d3c328a194 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -43,11 +43,13 @@ type SettledOutput = | { readonly structured: Record; 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 = [], @@ -419,5 +434,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) stepSettlement: () => stepSettlement, startAssistant, assistantMessageID: assistantMessageIDForTool, + progressTool, } } diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 05e8e59fbb..81465dc002 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -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 = [] 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")))) }, }), diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index fb5af18706..4f344b8fe4 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -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 = (effect: Effect.Effect) => 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) }], + }) }) }, }), diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index ea27dfe889..f9597a9a9a 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -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}` })), diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index ffd59bb489..32ddc4e2a1 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -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}` }))), }), diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts index 571b747bab..9ad886b8df 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -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 }).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 }) + .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 }), + ) }), ) diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index 6c50a809ec..14031dd948 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -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) }], + }), + ), ), }), }) diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 563159039c..3094fe8133 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -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 })) diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 41cd111e2f..7bbc7d189a 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -17,6 +17,7 @@ export type ExecuteInput = { readonly agent: AgentV2.ID readonly assistantMessageID: SessionMessage.ID readonly call: ToolCall + readonly progress?: (output: ToolOutput) => Effect.Effect } export interface Interface { @@ -61,11 +62,20 @@ const registryLayer = Layer.effect( } if (advertised && registration.identity !== advertised) return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } } + const progress = (output: ToolOutput) => { + const emit = input.progress + if (!emit) return Effect.void + return resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output }).pipe( + Effect.flatMap((bounded) => emit(bounded.output)), + Effect.ignore, + ) + } const pending = yield* settle(registration.tool, input.call, { sessionID: input.sessionID, agent: input.agent, assistantMessageID: input.assistantMessageID, toolCallID: input.call.id, + progress, }).pipe( Effect.map((output) => ({ output })), Effect.catchTag("LLM.ToolFailure", (failure) => diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index 5b439f9abe..8a0aa78afd 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -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 | 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 = []) => { + 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. @@ -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}` }))), }), }) diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 0cf9ee354f..8bc8061ac5 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -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))) }), }), diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 517d1ce65f..afdcbc32af 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -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 }], + }) }), }), }) diff --git a/packages/core/src/tool/todowrite.ts b/packages/core/src/tool/todowrite.ts index a746d524f0..83f12c0bf3 100644 --- a/packages/core/src/tool/todowrite.ts +++ b/packages/core/src/tool/todowrite.ts @@ -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" }))), }), }) diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index 2ce0868d99..30f2129e5a 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -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}` }))), }), }) diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 14c10377ee..2bcda8a2b3 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -181,7 +181,6 @@ const callMcp = ( 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}` }))) }, }), diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index 1438e52247..aeeb5dab8e 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -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", diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 513f5c6fda..c27bf7ae20 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -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> = [] 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)) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 1405108dc0..bf8409843f 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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> = [] 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> = [] 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() } }), }), diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 7bd3e0e339..b33c2de98f 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -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.", diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 8bf145eddb..55e4569f73 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -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, diff --git a/packages/core/test/tool-webfetch.test.ts b/packages/core/test/tool-webfetch.test.ts index 5a856ffaf4..6ce6df5853 100644 --- a/packages/core/test/tool-webfetch.test.ts +++ b/packages/core/test/tool-webfetch.test.ts @@ -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" }], }, }) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 9715dd5c7f..6a04bb6010 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -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" }], }, }) diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index 223de7a8b8..340ff45ade 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -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 { readonly sessionID: Session.ID readonly agent: Agent.ID readonly assistantMessageID: SessionMessage.ID readonly toolCallID: string + readonly progress: (state: State) => Effect.Effect } export type SchemaType = Schema.Codec @@ -28,6 +29,20 @@ export type AnyTool = Definition export const Failure = ToolFailure export type Failure = ToolFailure +const ResultTypeId = Symbol("@opencode-ai/plugin/Tool.Result") + +export interface State { + readonly output: Output + readonly content?: ReadonlyArray +} + +export interface Result extends State { + readonly [ResultTypeId]: Output +} + +export const result = (state: State): Result => + Object.freeze({ ...state, [ResultTypeId]: state.output }) + export class RegistrationError extends Schema.TaggedErrorClass()("Tool.RegistrationError", { name: Schema.String, message: Schema.String, @@ -37,72 +52,72 @@ 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, - Output extends SchemaType, - Structured extends SchemaType = Output, -> = { +type Config, Output extends SchemaType> = { readonly description: string readonly input: Input readonly output: Output - readonly structured?: Structured - readonly toStructuredOutput?: (input: { - readonly input: Schema.Schema.Type - readonly output: Output["Encoded"] - }) => Schema.Schema.Type readonly execute: ( input: Schema.Schema.Type, - context: Context, - ) => Effect.Effect, ToolFailure> - readonly toModelOutput?: (input: { - readonly input: Schema.Schema.Type - readonly output: Output["Encoded"] - }) => ReadonlyArray + context: Context>, + ) => Effect.Effect | Result>, ToolFailure> } -export type DynamicOutput = { - readonly structured: unknown - readonly content: ReadonlyArray -} +export type DynamicOutput = Result /** * 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 + readonly execute: (input: unknown, context: Context) => Effect.Effect +} + +export interface RuntimeContext extends Omit { + readonly progress?: (output: ToolOutput) => Effect.Effect } type Runtime = { readonly permission?: string readonly definition: (name: string) => ToolDefinition - readonly settle: (call: ToolCall, context: Context) => Effect.Effect + readonly settle: (call: ToolCall, context: RuntimeContext) => Effect.Effect } const runtimes = new WeakMap() -export function make< - Input extends SchemaType, - Output extends SchemaType, - Structured extends SchemaType = Output, ->(config: Config): Definition +export function make, Output extends SchemaType>( + config: Config, +): Definition export function make(config: DynamicConfig): AnyTool -export function make(config: Config | DynamicConfig): AnyTool { +export function make(config: Config | DynamicConfig): AnyTool { if ("jsonSchema" in config) return makeDynamic(config) return makeTyped(config) } -function makeTyped< - Input extends SchemaType, - Output extends SchemaType, - Structured extends SchemaType = Output, ->(config: Config): Definition { - const tool = Object.freeze({}) as Definition +function makeTyped, Output extends SchemaType>( + config: Config, +): Definition { + const tool = Object.freeze({}) as Definition const definitions = new Map() + + const project = ( + value: Schema.Schema.Type | Result>, + ): Effect.Effect => { + const state = stateOf(value) + return 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}`, + }), + ), + ) + } + runtimes.set(tool, { definition: (name) => { const cached = definitions.get(name) @@ -111,7 +126,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 +135,13 @@ 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) => + project(result(state)).pipe(Effect.flatMap(context.progress ?? (() => Effect.void)), Effect.ignore), + }) + .pipe(Effect.flatMap(project)), ), ), }) @@ -154,6 +151,10 @@ function makeTyped< function makeDynamic(config: DynamicConfig): AnyTool { const tool = Object.freeze({}) as AnyTool const definitions = new Map() + const project = (value: unknown) => { + const state = stateOf(value) + return ToolOutput.make(state.output, contentOf(state.output, state.content)) + } runtimes.set(tool, { definition: (name) => { const cached = definitions.get(name) @@ -169,12 +170,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?.(project(result(state))).pipe(Effect.ignore) ?? Effect.void, + }) + .pipe(Effect.map(project)), }) return tool } +function stateOf(value: Output | Result): State { + if (isResult(value)) return value + return { output: value } +} + +function isResult(value: unknown): value is Result { + return typeof value === "object" && value !== null && ResultTypeId in value +} + +function contentOf(output: unknown, content: ReadonlyArray | 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 +216,7 @@ export const withPermission = , 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) diff --git a/packages/sdk-next/src/tool.ts b/packages/sdk-next/src/tool.ts index eff4b809aa..a20f668930 100644 --- a/packages/sdk-next/src/tool.ts +++ b/packages/sdk-next/src/tool.ts @@ -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"