feat(plugin): add tool result content API

This commit is contained in:
Kit Langton 2026-06-30 23:20:18 -04:00
commit 3e583a3f5a
25 changed files with 316 additions and 197 deletions

View file

@ -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))

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() }
}),
}),

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.",

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,

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" }],
},
})

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" }],
},
})