feat(plugin): add tool progress reporting

This commit is contained in:
Dax Raad 2026-07-14 15:57:16 -04:00
commit 915be9b54d
36 changed files with 196 additions and 92 deletions

View file

@ -333,8 +333,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
tool: event.tool,
sessionID: event.sessionID,
agent: event.agent,
assistantMessageID: event.assistantMessageID,
toolCallID: event.toolCallID,
messageID: event.messageID,
callID: event.callID,
input: event.input,
}
return Reflect.apply(callback, undefined, [output]).pipe(
@ -347,8 +347,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
tool: event.tool,
sessionID: event.sessionID,
agent: event.agent,
assistantMessageID: event.assistantMessageID,
toolCallID: event.toolCallID,
messageID: event.messageID,
callID: event.callID,
input: event.input,
result: event.result,
output: event.output,

View file

@ -256,10 +256,22 @@ function fromPromiseTool(tool: AnyTool) {
if ("jsonSchema" in tool)
return Tool.make({
...tool,
execute: (input, context) => Effect.promise(() => tool.execute(input, context)),
execute: (input, context) =>
Effect.promise(() =>
tool.execute(input, {
...context,
progress: (update) => Effect.runPromise(context.progress(update)),
}),
),
})
return Tool.make({
...tool,
execute: (input, context) => Effect.promise(() => tool.execute(input, context)),
execute: (input, context) =>
Effect.promise(() =>
tool.execute(input, {
...context,
progress: (update) => Effect.runPromise(context.progress(update)),
}),
),
})
}

View file

@ -265,8 +265,18 @@ const layer = Layer.effect(
toolMaterialization.settle({
sessionID: session.id,
agent: agent.id,
assistantMessageID,
messageID: assistantMessageID,
call: event,
progress: (update) =>
serialized(
events.publish(SessionEvent.Tool.Progress, {
sessionID: session.id,
assistantMessageID,
callID: event.id,
structured: { ...update.structured },
content: [...update.content],
}),
),
}),
).pipe(
Effect.flatMap((settlement) =>

View file

@ -18,7 +18,7 @@ export const MANAGED_DIRECTORY = "tool-output"
export interface BoundInput {
readonly sessionID: SessionSchema.ID
readonly toolCallID: string
readonly callID: string
readonly output: ToolOutput
}

View file

@ -122,8 +122,8 @@ export const Plugin = {
return Effect.gen(function* () {
const permissionSource = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
messageID: context.messageID,
callID: context.callID,
}
if (input.oldString === input.newString) {
return yield* new ToolFailure({

View file

@ -113,12 +113,13 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
const output = yield* settle(
registration.tool,
{ type: "tool-call", id: context.toolCallID, name, input },
{ type: "tool-call", id: context.callID, name, input },
{
sessionID: context.sessionID,
agent: context.agent,
assistantMessageID: context.assistantMessageID,
toolCallID: context.toolCallID,
messageID: context.messageID,
callID: context.callID,
progress: context.progress,
},
).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
const outputFileParts = outputFiles(output)

View file

@ -72,7 +72,7 @@ export const Plugin = {
},
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
const cwd = path.resolve(location.directory, input.path ?? ".")
yield* fs

View file

@ -90,7 +90,7 @@ export const Plugin = {
},
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
const target = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs

View file

@ -12,8 +12,8 @@ export interface BeforeEvent {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly assistantMessageID: SessionMessage.ID
readonly toolCallID: string
readonly messageID: SessionMessage.ID
readonly callID: string
input: unknown
}
@ -21,8 +21,8 @@ export interface AfterEvent {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly assistantMessageID: SessionMessage.ID
readonly toolCallID: string
readonly messageID: SessionMessage.ID
readonly callID: string
readonly input: unknown
result: ToolResultValue
output?: ToolOutput

View file

@ -57,8 +57,8 @@ export const layer = Layer.effectDiscard(
agent: context.agent,
source: {
type: "tool",
messageID: context.assistantMessageID,
callID: context.toolCallID,
messageID: context.messageID,
callID: context.callID,
},
})
const result = yield* mcp

View file

@ -85,8 +85,8 @@ export const Plugin = {
return Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
messageID: context.messageID,
callID: context.callID,
}
if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.try({

View file

@ -73,7 +73,7 @@ export const Plugin = {
resources: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
.pipe(
Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })),
@ -84,7 +84,7 @@ export const Plugin = {
title: "Questions",
metadata: {
kind: "question",
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
tool: { messageID: context.messageID, callID: context.callID },
},
fields: [
toField(input.questions[0], 0),

View file

@ -62,8 +62,8 @@ export const Plugin = {
return Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
messageID: context.messageID,
callID: context.callID,
}
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
const external = target.externalDirectory

View file

@ -19,8 +19,14 @@ import { toSessionError } from "../session/to-session-error"
export type ExecuteInput = {
readonly sessionID: SessionSchema.ID
readonly agent: AgentV2.ID
readonly assistantMessageID: SessionMessage.ID
readonly messageID: SessionMessage.ID
readonly call: ToolCall
readonly progress?: (update: Progress) => Effect.Effect<void>
}
export interface Progress {
readonly structured: Readonly<Record<string, unknown>>
readonly content: ToolOutput["content"]
}
export interface Interface {
@ -65,8 +71,8 @@ const registryLayer = Layer.effect(
tool: input.call.name,
sessionID: input.sessionID,
agent: input.agent,
assistantMessageID: input.assistantMessageID,
toolCallID: input.call.id,
messageID: input.messageID,
callID: input.call.id,
input: input.call.input,
}
yield* toolHooks.runBefore(beforeEvent)
@ -76,8 +82,22 @@ const registryLayer = Layer.effect(
{
sessionID: input.sessionID,
agent: input.agent,
assistantMessageID: input.assistantMessageID,
toolCallID: input.call.id,
messageID: input.messageID,
callID: input.call.id,
progress: (update) =>
input.progress?.({
structured: update.structured,
content: (update.content ?? []).map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: {
type: "file" as const,
uri: `data:${part.mime};base64,${part.data}`,
mime: part.mime,
name: part.name,
},
),
}) ?? Effect.void,
},
).pipe(
Effect.map((output) => ({ output })),
@ -94,7 +114,7 @@ const registryLayer = Layer.effect(
} else {
const bounded = yield* resources.bound({
sessionID: input.sessionID,
toolCallID: input.call.id,
callID: input.call.id,
output: pending.output,
})
const result = ToolOutput.toResultValue(bounded.output)
@ -111,8 +131,8 @@ const registryLayer = Layer.effect(
tool: input.call.name,
sessionID: input.sessionID,
agent: input.agent,
assistantMessageID: input.assistantMessageID,
toolCallID: input.call.id,
messageID: input.messageID,
callID: input.call.id,
input: beforeEvent.input,
result: settlement.result,
output: settlement.output,

View file

@ -165,8 +165,8 @@ export const Plugin = {
Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
messageID: context.messageID,
callID: context.callID,
}
const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" })
const external = target.externalDirectory
@ -231,7 +231,7 @@ export const Plugin = {
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
id: context.toolCallID,
id: context.callID,
type: name,
title: input.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
@ -240,7 +240,7 @@ export const Plugin = {
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
yield* notifyWhenDone(context.sessionID, context.callID, input.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
@ -255,7 +255,7 @@ export const Plugin = {
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
yield* notifyWhenDone(context.sessionID, context.callID, input.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,

View file

@ -79,7 +79,7 @@ export const Plugin = {
save: [skill.id],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
const directory = path.dirname(skill.location)
const files =

View file

@ -136,8 +136,8 @@ export const Plugin = {
agent: context.agent,
source: {
type: "tool",
messageID: context.assistantMessageID,
callID: context.toolCallID,
messageID: context.messageID,
callID: context.callID,
},
})
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
@ -160,6 +160,9 @@ export const Plugin = {
)
const background = input.background === true
yield* context.progress({
structured: { sessionID: child.id, status: "running" },
})
const run = Effect.gen(function* () {
// The child session owns its agent/model (set at create); prompt only admits input.

View file

@ -141,7 +141,7 @@ export const Plugin = {
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
const { body, contentType } = yield* Effect.gen(function* () {

View file

@ -213,7 +213,7 @@ export const Plugin = {
metadata: { ...input, provider },
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
const text =

View file

@ -64,8 +64,8 @@ export const Plugin = {
Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
messageID: context.messageID,
callID: context.callID,
}
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory