feat(core): compact durable tool metadata (#38343)

This commit is contained in:
Kit Langton 2026-07-22 12:09:48 -04:00 committed by GitHub
commit 7a1f9764a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 169 additions and 14 deletions

View file

@ -157,6 +157,53 @@ function failedTool(inputID: string): V2Event[] {
] ]
} }
function successfulGrep(inputID: string): V2Event[] {
const text = "Found 2 matches\n/src/a.ts:\n Line 1: needle\n/src/b.ts:\n Line 2: needle"
return [
prompted(inputID),
{
id: "evt_grep_input",
created: 1,
type: "session.tool.input.started",
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
name: "grep",
},
},
{
id: "evt_grep_called",
created: 2,
type: "session.tool.called",
durable: { aggregateID: "ses_1", seq: 2, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
input: { pattern: "needle" },
executed: true,
},
},
{
id: "evt_grep_success",
created: 3,
type: "session.tool.success",
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
data: {
sessionID: "ses_1",
assistantMessageID: "msg_grep",
callID: "call_grep",
structured: { matches: 2 },
content: [{ type: "text", text }],
executed: false,
},
},
settled(),
]
}
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the // Runs one non-interactive prompt against a mocked SDK. `turn` produces the
// live events the prompt admission triggers, keyed by the generated message ID. // live events the prompt admission triggers, keyed by the generated message ID.
async function run(input: { async function run(input: {
@ -269,6 +316,32 @@ afterEach(() => {
}) })
describe("runNonInteractivePrompt", () => { describe("runNonInteractivePrompt", () => {
test("keeps formatted tool output and compact structured metadata in JSON", async () => {
const output = await capture({ format: "json", turn: successfulGrep })
const events = output.stdout
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({
type: "tool_use",
part: {
tool: "grep",
state: {
status: "completed",
output: expect.stringContaining("Found 2 matches"),
metadata: {
structured: { matches: 2 },
content: [{ type: "text", text: expect.stringContaining("/src/a.ts") }],
},
},
},
})
expect(events[0].part.state.metadata.structured).toEqual({ matches: 2 })
expect(events[0].part.state.metadata.result).toBeUndefined()
})
test("uses session.wait then reconciles projected output without a terminal event", async () => { test("uses session.wait then reconciles projected output without a terminal event", async () => {
const idle = Promise.withResolvers<void>() const idle = Promise.withResolvers<void>()
let done = false let done = false

View file

@ -8,7 +8,7 @@ import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location" import { Location } from "../location"
import { Ripgrep } from "../ripgrep" import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema" import { NonNegativeInt, RelativePath } from "../schema"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { Tool } from "./tool" import { Tool } from "./tool"
@ -25,6 +25,9 @@ export const Input = Schema.Struct({
}) })
export const Output = Schema.Array(FileSystem.Entry) export const Output = Schema.Array(FileSystem.Entry)
const StructuredOutput = Schema.Struct({
count: NonNegativeInt,
})
type ModelOutput = typeof Output.Encoded type ModelOutput = typeof Output.Encoded
/** Format raw search results into the concise line-oriented output models expect. */ /** Format raw search results into the concise line-oriented output models expect. */
@ -51,6 +54,8 @@ export const Plugin = {
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.", "Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
input: Input, input: Input,
output: Output, output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ count: output.length }),
toModelOutput: ({ output }) => [ toModelOutput: ({ output }) => [
{ {
type: "text", type: "text",

View file

@ -9,7 +9,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location" import { Location } from "../location"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { Ripgrep } from "../ripgrep" import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema" import { NonNegativeInt, RelativePath } from "../schema"
import { Tool } from "./tool" import { Tool } from "./tool"
export const name = "grep" export const name = "grep"
@ -30,6 +30,9 @@ export const Input = Schema.Struct({
}) })
export const Output = Schema.Array(FileSystem.Match) export const Output = Schema.Array(FileSystem.Match)
const StructuredOutput = Schema.Struct({
matches: NonNegativeInt,
})
type ModelOutput = typeof Output.Encoded type ModelOutput = typeof Output.Encoded
/** Format raw search matches into the familiar concise model output. */ /** Format raw search matches into the familiar concise model output. */
@ -65,6 +68,8 @@ export const Plugin = {
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.", "Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
input: Input, input: Input,
output: Output, output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ matches: output.length }),
toModelOutput: ({ output }) => [ toModelOutput: ({ output }) => [
{ {
type: "text", type: "text",

View file

@ -21,6 +21,10 @@ export const Output = Schema.Struct({
directory: Schema.String, directory: Schema.String,
output: Schema.String, output: Schema.String,
}) })
const StructuredOutput = Schema.Struct({
name: Output.fields.name,
directory: Output.fields.directory,
})
export const description = [ export const description = [
"Load a specialized skill when the task at hand matches one of the available skills in the instructions.", "Load a specialized skill when the task at hand matches one of the available skills in the instructions.",
@ -66,6 +70,8 @@ export const Plugin = {
description, description,
input: Input, input: Input,
output: Output, output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ name: output.name, directory: output.directory }),
toModelOutput: ({ output }) => [{ type: "text", text: output.output }], toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) => execute: (input, context) =>
Effect.gen(function* () { Effect.gen(function* () {

View file

@ -31,6 +31,10 @@ export const Output = Schema.Struct({
status: Schema.Literals(["completed", "running"]), status: Schema.Literals(["completed", "running"]),
output: Schema.String, output: Schema.String,
}) })
const StructuredOutput = Schema.Struct({
sessionID: Output.fields.sessionID,
status: Output.fields.status,
})
export const description = [ export const description = [
"Spawn a subagent: a child session running a configured agent with fresh context.", "Spawn a subagent: a child session running a configured agent with fresh context.",
@ -115,6 +119,8 @@ export const Plugin = {
description, description,
input: Input, input: Input,
output: Output, output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ sessionID: output.sessionID, status: output.status }),
toModelOutput: ({ output }) => [{ type: "text", text: output.output }], toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) => execute: (input, context) =>
Effect.gen(function* () { Effect.gen(function* () {

View file

@ -37,6 +37,9 @@ const Output = Schema.Struct({
format: Input.fields.format, format: Input.fields.format,
output: Schema.String, output: Schema.String,
}) })
const StructuredOutput = Schema.Struct({
contentType: Output.fields.contentType,
})
type Format = (typeof Input.Type)["format"] type Format = (typeof Input.Type)["format"]
@ -126,6 +129,8 @@ export const Plugin = {
description, description,
input: Input, input: Input,
output: Output, output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ contentType: output.contentType }),
toModelOutput: ({ output }) => [{ type: "text", text: output.output }], toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) => execute: (input, context) =>
Effect.gen(function* () { Effect.gen(function* () {

View file

@ -190,6 +190,9 @@ const Output = Schema.Struct({
provider: Provider, provider: Provider,
text: Schema.String, text: Schema.String,
}) })
const StructuredOutput = Schema.Struct({
provider: Output.fields.provider,
})
export const Plugin = { export const Plugin = {
id: "opencode.tool.websearch", id: "opencode.tool.websearch",
@ -206,6 +209,8 @@ export const Plugin = {
description, description,
input: Input, input: Input,
output: Output, output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ provider: output.provider }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }], toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: (input, context) => { execute: (input, context) => {
const provider = selectProvider(context.sessionID, config, config.provider) const provider = selectProvider(context.sessionID, config, config.provider)

View file

@ -86,8 +86,12 @@ describe("search tools", () => {
const glob = yield* settleTool(registry, call("glob", { pattern: "*" })) const glob = yield* settleTool(registry, call("glob", { pattern: "*" }))
const grep = yield* settleTool(registry, call("grep", { pattern: "needle" })) const grep = yield* settleTool(registry, call("grep", { pattern: "needle" }))
expect(glob.output?.structured).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT) expect(glob.output?.structured).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(grep.output?.structured).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT) expect(grep.output?.structured).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(glob.output?.content).toEqual([{ type: "text", text: glob.result.value }])
expect(grep.output?.content).toEqual([{ type: "text", text: grep.result.value }])
expect(String(glob.result.value).split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
expect(grep.result.value).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
}), }),
) )
}), }),

View file

@ -119,9 +119,12 @@ describe("SkillTool", () => {
...toolIdentity, ...toolIdentity,
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { id: "effect" } }, call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { id: "effect" } },
}), }),
).toMatchObject({ ).toEqual({
result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) }, result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) },
output: { structured: { name: "Effect" } }, output: {
structured: { name: "Effect", directory },
content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }],
},
}) })
expect(assertions).toMatchObject([ expect(assertions).toMatchObject([
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] }, { sessionID, action: "skill", resources: ["effect"], save: ["effect"] },

View file

@ -35,7 +35,8 @@ const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID:
const parentModel = ModelV2.Ref.make({ id: ModelV2.ID.make("parent"), providerID: ProviderV2.ID.make("test") }) const parentModel = ModelV2.Ref.make({ id: ModelV2.ID.make("parent"), providerID: ProviderV2.ID.make("test") })
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
const outputSessionID = (value: unknown) => Schema.decodeUnknownSync(SubagentTool.Output)(value).sessionID const outputSessionID = (value: unknown) =>
Schema.decodeUnknownSync(Schema.Struct({ sessionID: SessionV2.ID }))(value).sessionID
const executionNode = makeGlobalNode({ const executionNode = makeGlobalNode({
service: SessionExecution.Service, service: SessionExecution.Service,
@ -229,7 +230,17 @@ describe("SubagentTool", () => {
}, },
}) })
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText }) expect(settled).toMatchObject({
result: { type: "text", value: childText },
output: {
structured: { status: "completed" },
content: [{ type: "text", text: childText }],
},
})
expect(settled.output?.structured).toEqual({
sessionID: outputSessionID(settled.output?.structured),
status: "completed",
})
expect((yield* sessions.get(outputSessionID(settled.output?.structured))).parentID).toBe(parent.id) expect((yield* sessions.get(outputSessionID(settled.output?.structured))).parentID).toBe(parent.id)
}), }),
), ),
@ -264,8 +275,15 @@ describe("SubagentTool", () => {
}, },
}) })
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText }) expect(settled).toMatchObject({
result: { type: "text", value: childText },
output: {
structured: { status: "completed" },
content: [{ type: "text", text: childText }],
},
})
const child = yield* sessions.get(outputSessionID(settled.output?.structured)) const child = yield* sessions.get(outputSessionID(settled.output?.structured))
expect(settled.output?.structured).toEqual({ sessionID: child.id, status: "completed" })
expect(progress[0]?.structured).toEqual({ sessionID: child.id, status: "running" }) expect(progress[0]?.structured).toEqual({ sessionID: child.id, status: "running" })
expect(child).toMatchObject({ expect(child).toMatchObject({
parentID: parent.id, parentID: parent.id,
@ -361,8 +379,10 @@ describe("SubagentTool", () => {
const childID = outputSessionID(settled.output?.structured) const childID = outputSessionID(settled.output?.structured)
expect(settled.output?.structured).toMatchObject({ expect(settled.output?.structured).toMatchObject({
status: "running", status: "running",
output: expect.stringContaining(`id: ${childID}`),
}) })
expect(settled.output?.structured).toEqual({ sessionID: childID, status: "running" })
expect(settled.result).toEqual({ type: "text", value: expect.stringContaining(`id: ${childID}`) })
expect(settled.output?.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }])
const admission = Array.from(yield* Fiber.join(admitted))[0] const admission = Array.from(yield* Fiber.join(admitted))[0]
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`) expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)

View file

@ -96,7 +96,7 @@ describe("WebFetchTool registration", () => {
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({ expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
result: { type: "text", value: "hello" }, result: { type: "text", value: "hello" },
output: { output: {
structured: { url, contentType: "text/plain", format: "text", output: "hello" }, structured: { contentType: "text/plain" },
content: [{ type: "text", text: "hello" }], content: [{ type: "text", text: "hello" }],
}, },
}) })

View file

@ -244,7 +244,7 @@ describe("WebSearchTool registration", () => {
expect(settled).toEqual({ expect(settled).toEqual({
result: { type: "text", value: "parallel results" }, result: { type: "text", value: "parallel results" },
output: { output: {
structured: { provider: "parallel", text: "parallel results" }, structured: { provider: "parallel" },
content: [{ type: "text", text: "parallel results" }], content: [{ type: "text", text: "parallel results" }],
}, },
}) })

View file

@ -2669,8 +2669,7 @@ function WebFetch(props: ToolProps) {
function WebSearch(props: ToolProps) { function WebSearch(props: ToolProps) {
return ( return (
<InlineTool icon="◈" pending="Searching web..." complete={stringValue(props.input.query)} part={props.part}> <InlineTool icon="◈" pending="Searching web..." complete={stringValue(props.input.query)} part={props.part}>
{webSearchProviderLabel(props.metadata.provider)} "{stringValue(props.input.query)}"{" "} {webSearchProviderLabel(props.metadata.provider)} "{stringValue(props.input.query)}"
<Show when={finiteNumber(props.metadata.numResults)}>({finiteNumber(props.metadata.numResults)} results)</Show>
</InlineTool> </InlineTool>
) )
} }

View file

@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { normalizeTool, toolInlineInfo, toolOutputText, toolPath, toolScroll } from "../../src/mini/tool" import { normalizeTool, toolInlineInfo, toolOutputText, toolPath, toolScroll } from "../../src/mini/tool"
import { canonicalToolPart } from "./fixture/tool-part"
describe("Mini tool presentation", () => { describe("Mini tool presentation", () => {
test("uses V2 shell output without the model-facing status", () => { test("uses V2 shell output without the model-facing status", () => {
@ -105,6 +106,29 @@ describe("Mini tool presentation", () => {
).toBe('→ Skill "effect"') ).toBe('→ Skill "effect"')
}) })
test("renders compact search metadata", () => {
expect(
toolInlineInfo(
canonicalToolPart("glob", {
status: "completed",
input: { pattern: "*.ts" },
structured: { count: 3 },
content: [],
}),
).description,
).toBe("3 matches")
expect(
toolInlineInfo(
canonicalToolPart("grep", {
status: "completed",
input: { pattern: "needle" },
structured: { matches: 1 },
content: [],
}),
).description,
).toBe("1 match")
})
test("keeps segment-safe contained tool paths relative", () => { test("keeps segment-safe contained tool paths relative", () => {
expect(toolPath("..cache/result.txt", { directory: "/work/project" })).toBe("..cache/result.txt") expect(toolPath("..cache/result.txt", { directory: "/work/project" })).toBe("..cache/result.txt")
expect(toolPath("../shared/result.txt", { directory: "/work/project" })).toBe("/work/shared/result.txt") expect(toolPath("../shared/result.txt", { directory: "/work/project" })).toBe("/work/shared/result.txt")