Compare commits

...

8 Commits

Author SHA1 Message Date
Kit Langton cf7f182091 test(core): cover multi-line patch summaries 2026-07-20 15:37:32 -04:00
Kit Langton a9bc065996 fix(core): balance truncated patch summaries 2026-07-20 15:27:40 -04:00
Kit Langton 98be51b74c fix(core): harden structured output invariants 2026-07-20 15:24:57 -04:00
Kit Langton f7a72fdf32 fix(core): preserve bounded tool receipts 2026-07-20 14:58:58 -04:00
Kit Langton aa1f91e0d0 fix(core): close tool output boundary gaps 2026-07-20 14:34:50 -04:00
Kit Langton c781ef420c fix(core): preserve patch receipt metadata 2026-07-20 14:16:27 -04:00
Kit Langton 74993a4bcf test(core): update structured receipt coverage 2026-07-20 14:08:05 -04:00
Kit Langton 2deaa7786c fix(core): bound tool structured output 2026-07-20 14:01:25 -04:00
35 changed files with 1394 additions and 321 deletions
+43 -11
View File
@@ -40,6 +40,7 @@ const layer = Layer.effect(
const db = (yield* Database.Service).db const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service const compaction = yield* SessionCompaction.Service
const title = yield* SessionTitle.Service const title = yield* SessionTitle.Service
const outputs = yield* ToolOutputStore.Service
// Title generation is a side effect of the first step; it must not delay step continuation. // Title generation is a side effect of the first step; it must not delay step continuation.
// Tracked per process so repeated wakes before the second user message arrives don't // Tracked per process so repeated wakes before the second user message arrives don't
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history. // re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
@@ -116,7 +117,7 @@ const layer = Layer.effect(
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = [] const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
let needsContinuation = false let needsContinuation = false
const startSnapshot = yield* snapshots.capture() const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(events, { const publisher = createLLMEventPublisher(events, outputs, {
sessionID: session.id, sessionID: session.id,
agent: agent.id, agent: agent.id,
// The selected catalog identity, not model.id: route-level ids are provider API // The selected catalog identity, not model.id: route-level ids are provider API
@@ -165,13 +166,25 @@ const layer = Layer.effect(
call: event, call: event,
progress: (update) => progress: (update) =>
serialized( serialized(
events.publish(SessionEvent.Tool.Progress, { Effect.gen(function* () {
sessionID: session.id, const bounded = yield* outputs.bound({
assistantMessageID, sessionID: session.id,
callID: event.id, callID: event.id,
structured: { ...update.structured }, output: update,
content: [...update.content], })
}), const structured = bounded.output.structured
const entries =
typeof structured === "object" && structured !== null && !Array.isArray(structured)
? Object.entries(structured)
: yield* Effect.die(new Error("Tool progress structured output must be an object"))
yield* events.publish(SessionEvent.Tool.Progress, {
sessionID: session.id,
assistantMessageID,
callID: event.id,
structured: Object.fromEntries(entries),
content: [...bounded.output.content],
})
}).pipe(Effect.orDie),
), ),
}), }),
).pipe( ).pipe(
@@ -261,6 +274,11 @@ const layer = Layer.effect(
} }
yield* serialized(publisher.failAssistant(error)) yield* serialized(publisher.failAssistant(error))
} }
if (streamFailure instanceof ToolOutputStore.StorageError) {
const error = toSessionError(streamFailure)
yield* serialized(publisher.failUnsettledTools(error))
yield* serialized(publisher.failAssistant(error))
}
// Provider error events only arrive from the stream, so the flag is final here. // Provider error events only arrive from the stream, so the flag is final here.
const providerFailed = publisher.hasProviderError() const providerFailed = publisher.hasProviderError()
@@ -287,8 +305,21 @@ const layer = Layer.effect(
// settles so the model may recover. A typed infrastructure failure (tool output // settles so the model may recover. A typed infrastructure failure (tool output
// could not be persisted) also fails the assistant and then fails the drain. // could not be persisted) also fails the assistant and then fails the drain.
const settledFailure = settledCauses.find((cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause)) const settledFailure = settledCauses.find((cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause))
const infrastructureFailure = settledCauses.find(
(cause) =>
Option.getOrUndefined(Cause.findErrorOption(cause)) instanceof ToolOutputStore.StorageError ||
cause.reasons.some(
(reason) => Cause.isDieReason(reason) && reason.defect instanceof ToolOutputStore.StorageError,
),
)
const typedError = infrastructureFailure
? Option.getOrUndefined(Cause.findErrorOption(infrastructureFailure))
: undefined
const storageDefect = infrastructureFailure?.reasons.find(
(reason) => Cause.isDieReason(reason) && reason.defect instanceof ToolOutputStore.StorageError,
)
const infraError = const infraError =
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure)) typedError ?? (storageDefect && Cause.isDieReason(storageDefect) ? storageDefect.defect : undefined)
if (settledFailure !== undefined) { if (settledFailure !== undefined) {
const failure = infraError ?? Cause.squash(settledFailure) const failure = infraError ?? Cause.squash(settledFailure)
const error = toSessionError(failure) const error = toSessionError(failure)
@@ -342,8 +373,8 @@ const layer = Layer.effect(
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause) if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (userDeclined) return yield* Effect.interrupt if (userDeclined) return yield* Effect.interrupt
if ((toolsInterrupted || infraError !== undefined) && settledFailure) if (infraError !== undefined && infrastructureFailure) return yield* Effect.failCause(infrastructureFailure)
return yield* Effect.failCause(settledFailure) if (toolsInterrupted && settledFailure) return yield* Effect.failCause(settledFailure)
if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause) if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
if (stepFailure) return yield* new StepFailedError({ error: stepFailure }) if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
return { return {
@@ -500,6 +531,7 @@ export const node = makeLocationNode({
SessionCompaction.node, SessionCompaction.node,
SessionTitle.node, SessionTitle.node,
Snapshot.node, Snapshot.node,
ToolOutputStore.node,
Database.node, Database.node,
], ],
}) })
@@ -11,6 +11,7 @@ import { AgentV2 } from "../../agent"
import { Snapshot } from "../../snapshot" import { Snapshot } from "../../snapshot"
import { RelativePath } from "../../schema" import { RelativePath } from "../../schema"
import { SessionUsage } from "../usage" import { SessionUsage } from "../usage"
import { ToolOutputStore } from "../../tool-output-store"
type Input = { type Input = {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
@@ -34,18 +35,22 @@ const message = (value: unknown) => {
} }
type SettledOutput = type SettledOutput =
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] } | ToolOutput
| { readonly error: SessionError.Error } | { readonly error: SessionError.Error }
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => { const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } } if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } }
const settled = value ?? ToolOutput.fromResultValue(result) const settled = value ?? ToolOutput.fromResultValue(result)
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`) if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
return { structured: record(settled.structured), content: settled.content } return settled
} }
/** Persist one step without executing tools or starting a continuation step. */ /** Persist one step without executing tools or starting a continuation step. */
export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish">, input: Input) => { export const createLLMEventPublisher = (
events: Pick<EventV2.Interface, "publish">,
outputs: Pick<ToolOutputStore.Interface, "bound">,
input: Input,
) => {
const tools = new Map< const tools = new Map<
string, string,
{ {
@@ -405,7 +410,6 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
if (event.result.type === "error") return if (event.result.type === "error") return
return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`)) return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`))
} }
tool.settled = true
const result = error ? { error } : settledOutput(event.output, event.result) const result = error ? { error } : settledOutput(event.output, event.result)
const executed = event.providerExecuted === true || tool.providerExecuted const executed = event.providerExecuted === true || tool.providerExecuted
const resultState = providerState(event.providerMetadata) const resultState = providerState(event.providerMetadata)
@@ -419,17 +423,26 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
executed, executed,
resultState, resultState,
}) })
tool.settled = true
return return
} }
const bounded = yield* outputs.bound({
sessionID: input.sessionID,
callID: event.id,
output: { ...result, structured: record(result.structured) },
})
yield* events.publish(SessionEvent.Tool.Success, { yield* events.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
callID: event.id, callID: event.id,
...result, structured: record(bounded.output.structured),
content: bounded.output.content,
// Provider-executed results remain verbatim for provider-history replay.
...(executed ? { result: event.result } : {}), ...(executed ? { result: event.result } : {}),
executed, executed,
resultState, resultState,
}) })
tool.settled = true
return return
} }
case "tool-error": { case "tool-error": {
+95 -33
View File
@@ -1,7 +1,7 @@
export * as ToolOutputStore from "./tool-output-store" export * as ToolOutputStore from "./tool-output-store"
import path from "path" import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" import { Context, Duration, Effect, Layer, Option, Predicate, Schedule, Schema } from "effect"
import { Config } from "./config" import { Config } from "./config"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
import { Global } from "./global" import { Global } from "./global"
@@ -12,14 +12,21 @@ import type { ToolOutput } from "@opencode-ai/ai"
export const MAX_LINES = 2_000 export const MAX_LINES = 2_000
export const MAX_BYTES = 50 * 1024 export const MAX_BYTES = 50 * 1024
export const MAX_STRUCTURED_BYTES = 16 * 1024
export const RETENTION = Duration.days(7) export const RETENTION = Duration.days(7)
export const MANAGED_DIRECTORY = "tool-output" export const MANAGED_DIRECTORY = "tool-output"
export interface Limits {
readonly maxLines: number
readonly maxBytes: number
}
export interface BoundInput { export interface BoundInput {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly callID: string readonly callID: string
readonly output: ToolOutput readonly output: ToolOutput
readonly propagateTruncation?: boolean
} }
export interface BoundResult { export interface BoundResult {
@@ -40,7 +47,7 @@ export class StorageError extends Schema.TaggedErrorClass<StorageError>()("ToolO
export type Error = StorageError export type Error = StorageError
export interface Interface { export interface Interface {
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }> readonly limits: () => Effect.Effect<Limits>
readonly bound: (input: BoundInput) => Effect.Effect<BoundResult, Error> readonly bound: (input: BoundInput) => Effect.Effect<BoundResult, Error>
readonly cleanup: () => Effect.Effect<void> readonly cleanup: () => Effect.Effect<void>
} }
@@ -49,26 +56,31 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
const takePrefix = (input: string, maximumBytes: number) => { const takePrefix = (input: string, maximumBytes: number) => {
let bytes = 0 let bytes = 0
let content = "" let end = 0
for (const char of input) { for (const char of input) {
const size = Buffer.byteLength(char, "utf-8") const size = Buffer.byteLength(char, "utf-8")
if (bytes + size > maximumBytes) break if (bytes + size > maximumBytes) break
content += char end += char.length
bytes += size bytes += size
} }
return content return input.slice(0, end)
} }
const takeSuffix = (input: string, maximumBytes: number) => { const takeSuffix = (input: string, maximumBytes: number) => {
let bytes = 0 let bytes = 0
const content: string[] = [] let start = input.length
for (const char of Array.from(input).toReversed()) { while (start > 0) {
const codeUnit = input.charCodeAt(start - 1)
const previous = start > 1 ? input.charCodeAt(start - 2) : 0
const next =
codeUnit >= 0xdc00 && codeUnit <= 0xdfff && previous >= 0xd800 && previous <= 0xdbff ? start - 2 : start - 1
const char = input.slice(next, start)
const size = Buffer.byteLength(char, "utf-8") const size = Buffer.byteLength(char, "utf-8")
if (bytes + size > maximumBytes) break if (bytes + size > maximumBytes) break
content.unshift(char) start = next
bytes += size bytes += size
} }
return content.join("") return input.slice(start)
} }
const preview = (text: string, maxLines: number, maxBytes: number) => { const preview = (text: string, maxLines: number, maxBytes: number) => {
@@ -109,6 +121,27 @@ const lineCount = (text: string) => {
return count return count
} }
export const contextualOverflow = (output: ToolOutput, limits: Limits) => {
const contextual = output.content
.filter((item) => item.type === "text")
.map((item) => item.text)
.join("")
return Buffer.byteLength(contextual, "utf-8") > limits.maxBytes || lineCount(contextual) > limits.maxLines
}
export const propagateTruncation = (output: ToolOutput, limits: Limits): ToolOutput =>
contextualOverflow(output, limits) &&
Predicate.isObject(output.structured) &&
"truncated" in output.structured &&
typeof output.structured.truncated === "boolean"
? { ...output, structured: { ...output.structured, truncated: true } }
: output
const record = (value: unknown): Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
? Object.fromEntries(Object.entries(value))
: { value }
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@@ -128,7 +161,6 @@ const layer = Layer.effect(
const write = Effect.fn("ToolOutputStore.write")(function* (content: string) { const write = Effect.fn("ToolOutputStore.write")(function* (content: string) {
const file = path.join(directory, `tool_${Identifier.ascending()}`) const file = path.join(directory, `tool_${Identifier.ascending()}`)
yield* fs.ensureDir(directory).pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
yield* fs yield* fs
.writeFileString(file, content, { flag: "wx" }) .writeFileString(file, content, { flag: "wx" })
.pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause }))) .pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
@@ -139,37 +171,67 @@ const layer = Layer.effect(
const outputLimits = yield* limits() const outputLimits = yield* limits()
const media = input.output.content.filter((item) => item.type === "file") const media = input.output.content.filter((item) => item.type === "file")
const text = input.output.content.filter((item) => item.type === "text") const text = input.output.content.filter((item) => item.type === "text")
const contextual = const encoded = yield* Effect.try({
input.output.content.length === 0 try: () => JSON.stringify(record(input.output.structured)),
? yield* Effect.try({ catch: (cause) => new StorageError({ operation: "encode", cause }),
try: () => JSON.stringify(input.output.structured, null, 2) ?? String(input.output.structured), })
catch: (cause) => new StorageError({ operation: "encode", cause }), const decoded: unknown = JSON.parse(encoded)
}) const structured =
: text.map((item) => item.text).join("") typeof decoded === "object" && decoded !== null && !Array.isArray(decoded)
if ( ? Object.fromEntries(Object.entries(decoded))
lineCount(contextual) <= outputLimits.maxLines && : yield* Effect.die(new Error("Durable tool structured output must be an object"))
Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes const encodedBytes = Buffer.byteLength(encoded, "utf-8")
) const contextual = input.output.content.length === 0 ? encoded : text.map((item) => item.text).join("")
const contextualBytes = input.output.content.length === 0 ? encodedBytes : Buffer.byteLength(contextual, "utf-8")
const structuredOverflow = encodedBytes > MAX_STRUCTURED_BYTES
const contentOverflow = contextualBytes > outputLimits.maxBytes || lineCount(contextual) > outputLimits.maxLines
if (!structuredOverflow && !contentOverflow)
return { return {
output: input.output, output: { ...input.output, structured },
outputPaths: [], outputPaths: [],
} }
const outputPath = yield* write(contextual) yield* fs.ensureDir(directory).pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
const marker = `... output truncated; full content saved to ${outputPath} ...` const structuredPath = structuredOverflow ? yield* write(encoded) : undefined
const contextualPath = contentOverflow
? input.output.content.length === 0 && structuredPath
? structuredPath
: yield* write(contextual).pipe(
Effect.onError(() =>
structuredPath ? fs.remove(structuredPath).pipe(Effect.catch(() => Effect.void)) : Effect.void,
),
)
: undefined
return { return {
output: { output: {
structured: input.output.structured, structured: structuredPath
content: [ ? { _truncated: true, _bytes: encodedBytes, _outputPath: structuredPath }
{ : contentOverflow &&
type: "text" as const, input.propagateTruncation === true &&
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes), Predicate.isObject(structured) &&
}, "truncated" in structured &&
...media, typeof structured.truncated === "boolean"
], ? { ...structured, truncated: true }
: structured,
content: contentOverflow
? [
{
type: "text" as const,
text: boundedPreview(
contextual,
`... output truncated; full content saved to ${contextualPath} ...`,
outputLimits.maxLines,
outputLimits.maxBytes,
),
},
...media,
]
: input.output.content.length === 0 && structuredPath
? [{ type: "text" as const, text: contextual }]
: input.output.content,
}, },
outputPaths: [outputPath], outputPaths: [...new Set([structuredPath, contextualPath].filter((value) => value !== undefined))],
} }
}) })
+10
View File
@@ -16,6 +16,7 @@ import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation" import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { Tool } from "./tool" import { Tool } from "./tool"
import { ToolStructured } from "./structured"
export const name = "edit" export const name = "edit"
@@ -103,6 +104,15 @@ export const Plugin = {
"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.", "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, input: Input,
output: Output, output: Output,
structured: Output,
toStructuredOutput: ({ output }) =>
ToolStructured.fit((maximumBytes) => ({
...output,
files: output.files.map((file) => ({
...file,
patch: ToolStructured.patch(file.patch, maximumBytes),
})),
})),
toModelOutput: ({ input, output }) => [ toModelOutput: ({ input, output }) => [
{ type: "text", text: toModelOutput(output, input.oldString, input.newString) }, { type: "text", text: toModelOutput(output, input.oldString, input.newString) },
], ],
+7 -2
View File
@@ -49,7 +49,7 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
) => { ) => {
const tools: Record<string, Tool.Definition<never>> = {} const tools: Record<string, Tool.Definition<never>> = {}
for (const [name, registration] of registrations) { for (const [name, registration] of registrations) {
const child = definition(name, registration.tool) const child = definition(name, codeModeTool(registration.tool))
const value = Tool.make({ const value = Tool.make({
description: child.description, description: child.description,
input: child.inputSchema, input: child.inputSchema,
@@ -96,7 +96,7 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
Effect.gen(function* () { Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1) const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
const output = yield* settle( const output = yield* settle(
registration.tool, codeModeTool(registration.tool),
{ type: "tool-call", id: context.callID, name, input }, { type: "tool-call", id: context.callID, name, input },
{ {
sessionID: context.sessionID, sessionID: context.sessionID,
@@ -141,6 +141,11 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
}) })
} }
function codeModeTool(tool: AnyTool): AnyTool {
if ("jsonSchema" in tool || tool.codeModeOutput !== "output") return tool
return { ...tool, structured: undefined }
}
function displayInput(input: unknown): Record<string, unknown> | undefined { function displayInput(input: unknown): Record<string, unknown> | undefined {
if (input === null || input === undefined) return if (input === null || input === undefined) return
if (typeof input !== "object" || Array.isArray(input)) return { input } if (typeof input !== "object" || Array.isArray(input)) return { input }
+4
View File
@@ -26,6 +26,7 @@ export const Input = Schema.Struct({
export const Output = Schema.Array(FileSystem.Entry) export const Output = Schema.Array(FileSystem.Entry)
type ModelOutput = typeof Output.Encoded type ModelOutput = typeof Output.Encoded
const StructuredOutput = Schema.Struct({ count: Schema.Number })
/** Format raw search results into the concise line-oriented output models expect. */ /** Format raw search results into the concise line-oriented output models expect. */
export const toModelOutput = (output: ModelOutput) => { export const toModelOutput = (output: ModelOutput) => {
@@ -51,6 +52,9 @@ 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,
codeModeOutput: "output",
toStructuredOutput: ({ output }) => ({ count: output.length }),
toModelOutput: ({ output }) => [ toModelOutput: ({ output }) => [
{ {
type: "text", type: "text",
+4
View File
@@ -31,6 +31,7 @@ export const Input = Schema.Struct({
export const Output = Schema.Array(FileSystem.Match) export const Output = Schema.Array(FileSystem.Match)
type ModelOutput = typeof Output.Encoded type ModelOutput = typeof Output.Encoded
const StructuredOutput = Schema.Struct({ matches: Schema.Number })
/** Format raw search matches into the familiar concise model output. */ /** Format raw search matches into the familiar concise model output. */
export const toModelOutput = (output: ModelOutput) => { export const toModelOutput = (output: ModelOutput) => {
@@ -65,6 +66,9 @@ 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,
codeModeOutput: "output",
toStructuredOutput: ({ output }) => ({ matches: output.length }),
toModelOutput: ({ output }) => [ toModelOutput: ({ output }) => [
{ {
type: "text", type: "text",
+10
View File
@@ -11,6 +11,7 @@ import { LocationMutation } from "../location-mutation"
import { Patch } from "../patch" import { Patch } from "../patch"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { Tool } from "./tool" import { Tool } from "./tool"
import { ToolStructured } from "./structured"
export const name = "patch" export const name = "patch"
@@ -72,6 +73,15 @@ export const Plugin = {
"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.", "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, input: Input,
output: Output, output: Output,
structured: Output,
toStructuredOutput: ({ output }) =>
ToolStructured.fit((maximumBytes) => ({
...output,
files: output.files.map((file) => ({
...file,
patch: ToolStructured.patch(file.patch, maximumBytes),
})),
})),
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) => { execute: (input, context) => {
const applied: Array<typeof Applied.Type> = [] const applied: Array<typeof Applied.Type> = []
+8
View File
@@ -7,6 +7,7 @@ import { Form } from "../form"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question" import { QuestionV2 } from "../question"
import { Tool } from "./tool" import { Tool } from "./tool"
import { ToolStructured } from "./structured"
export const name = "question" export const name = "question"
@@ -63,6 +64,13 @@ export const Plugin = {
description, description,
input: Input, input: Input,
output: Output, output: Output,
structured: Output,
toStructuredOutput: ({ output }) =>
ToolStructured.fit((maximumBytes) => ({
answers: output.answers.map((answers) =>
answers.map((answer) => ToolStructured.truncate(answer, maximumBytes)),
),
})),
toModelOutput: ({ input, output }) => [ toModelOutput: ({ input, output }) => [
{ type: "text", text: toModelOutput(input.questions, output.answers) }, { type: "text", text: toModelOutput(input.questions, output.answers) },
], ],
+3 -1
View File
@@ -239,6 +239,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
let line = 1 let line = 1
let bytes = 0 let bytes = 0
let next: number | undefined let next: number | undefined
let lineTruncated = false
const append = (input: string) => { const append = (input: string) => {
if (line < offset) { if (line < offset) {
line++ line++
@@ -249,6 +250,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
return false return false
} }
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
if (text !== input) lineTruncated = true
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0) const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
if (bytes + size > MAX_READ_BYTES) { if (bytes + size > MAX_READ_BYTES) {
next = line next = line
@@ -314,7 +316,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
content: lines.join("\n"), content: lines.join("\n"),
mime: FSUtil.mimeType(real), mime: FSUtil.mimeType(real),
offset, offset,
truncated: next !== undefined, truncated: next !== undefined || lineTruncated,
...(next === undefined ? {} : { next }), ...(next === undefined ? {} : { next }),
}) })
}), }),
+56 -7
View File
@@ -28,6 +28,31 @@ const LocationInput = Schema.Struct({
}) })
const Input = LocationInput const Input = LocationInput
const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage]) const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
const StructuredOutput = Schema.Union([
Schema.Struct({
type: Schema.Literal("file"),
uri: Schema.String,
name: Schema.String.pipe(Schema.optional),
encoding: FileSystem.Content.fields.encoding,
mime: Schema.String,
bytes: Schema.Number,
truncated: Schema.Boolean,
}),
Schema.Struct({
type: Schema.Literal("text-page"),
mime: Schema.String,
offset: Schema.Number,
bytes: Schema.Number,
truncated: Schema.Boolean,
next: Schema.Number.pipe(Schema.optional),
}),
Schema.Struct({
type: Schema.Literal("list-page"),
count: Schema.Number,
truncated: Schema.Boolean,
next: Schema.Number.pipe(Schema.optional),
}),
])
export const Plugin = { export const Plugin = {
id: "opencode.tool.read", id: "opencode.tool.read",
@@ -48,15 +73,39 @@ export const Plugin = {
"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.", "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, input: Input,
output: Output, output: Output,
structured: Schema.toEncoded(Output), structured: StructuredOutput,
// Image base64 reaches the model through content items (normalized generically codeModeOutput: "output",
// at tool settlement); persisting a second copy in structured would store the contentTruncation: true,
// original unresized bytes in the message row. toStructuredOutput: ({ output }) => {
toStructuredOutput: ({ output }) => if ("encoding" in output)
"encoding" in output && output.encoding === "base64" ? { ...output, content: "" } : output, return {
type: "file" as const,
uri: output.uri,
name: output.name,
encoding: output.encoding,
mime: output.mime,
bytes: Buffer.byteLength(output.content, output.encoding === "base64" ? "base64" : "utf-8"),
truncated: false,
}
if ("content" in output)
return {
type: output.type,
mime: output.mime,
offset: output.offset,
bytes: Buffer.byteLength(output.content, "utf-8"),
truncated: output.truncated,
next: output.next,
}
return {
type: "list-page" as const,
count: output.entries.length,
truncated: output.truncated,
next: output.next,
}
},
toModelOutput: ({ input, output }) => { toModelOutput: ({ input, output }) => {
if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime))
return [] return [{ type: "text", text: JSON.stringify(output) }]
return [ return [
{ type: "text", text: "Image read successfully" }, { type: "text", text: "Image read successfully" },
{ type: "file", data: output.content, mime: output.mime, name: input.path }, { type: "file", data: output.content, mime: output.mime, name: input.path },
+54 -30
View File
@@ -83,18 +83,16 @@ const registryLayer = Layer.effect(
const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1] const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1]
if (base64 === undefined) return Effect.succeed(item) if (base64 === undefined) return Effect.succeed(item)
const resource = item.name ?? `${item.mime} tool output` const resource = item.name ?? `${item.mime} tool output`
return image return image.normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime }).pipe(
.normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime }) Effect.map((result) => ({
.pipe( ...item,
Effect.map((result) => ({ uri: `data:${result.mime};base64,${result.content}`,
...item, mime: result.mime,
uri: `data:${result.mime};base64,${result.content}`, })),
mime: result.mime, Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)),
})), Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)),
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)), Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)),
Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)), )
Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)),
)
}) })
const note = (reason: "decode" | "size", text: string) => { const note = (reason: "decode" | "size", text: string) => {
const count = normalized.filter((item) => item === reason).length const count = normalized.filter((item) => item === reason).length
@@ -118,6 +116,7 @@ const registryLayer = Layer.effect(
const settleTool = Effect.fn("ToolRegistry.settleTool")(function* (input: ExecuteInput, tool: AnyTool) { const settleTool = Effect.fn("ToolRegistry.settleTool")(function* (input: ExecuteInput, tool: AnyTool) {
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleTool. // Hooks fire only for hosted/local tools; provider-executed calls never reach settleTool.
const propagateTruncation = !("jsonSchema" in tool) && tool.contentTruncation === true
const beforeEvent: ToolHooks.BeforeEvent = { const beforeEvent: ToolHooks.BeforeEvent = {
tool: input.call.name, tool: input.call.name,
sessionID: input.sessionID, sessionID: input.sessionID,
@@ -149,7 +148,28 @@ const registryLayer = Layer.effect(
name: part.name, name: part.name,
}, },
), ),
).pipe(Effect.flatMap((content) => progress({ structured: update.structured, content }))) ).pipe(
Effect.flatMap((content) =>
propagateTruncation
? resources
.limits()
.pipe(
Effect.map((limits) =>
ToolOutputStore.propagateTruncation({ structured: update.structured, content }, limits),
),
)
: Effect.succeed({ structured: update.structured, content }),
),
Effect.flatMap((output) => {
const structured = output.structured
const entries =
typeof structured === "object" && structured !== null && !Array.isArray(structured)
? Object.entries(structured)
: undefined
if (!entries) return Effect.die(new Error("Tool progress structured output must be an object"))
return progress({ structured: Object.fromEntries(entries), content: output.content })
}),
)
}, },
}, },
).pipe( ).pipe(
@@ -165,21 +185,13 @@ const registryLayer = Layer.effect(
if ("result" in pending) { if ("result" in pending) {
settlement = pending settlement = pending
} else { } else {
const bounded = yield* resources.bound({ const output = {
sessionID: input.sessionID, structured: pending.output.structured,
callID: input.call.id, content: yield* normalizeImages(pending.output.content),
output: { structured: pending.output.structured, content: yield* normalizeImages(pending.output.content) }, }
}) settlement = { result: ToolOutput.toResultValue(output), output }
const result = ToolOutput.toResultValue(bounded.output)
settlement =
result.type === "error"
? bounded.outputPaths.length > 0
? { result, outputPaths: bounded.outputPaths }
: { result }
: bounded.outputPaths.length > 0
? { result, output: bounded.output, outputPaths: bounded.outputPaths }
: { result, output: bounded.output }
} }
const initialResult = settlement.result
const afterEvent: ToolHooks.AfterEvent = { const afterEvent: ToolHooks.AfterEvent = {
tool: input.call.name, tool: input.call.name,
sessionID: input.sessionID, sessionID: input.sessionID,
@@ -192,10 +204,22 @@ const registryLayer = Layer.effect(
outputPaths: settlement.outputPaths, outputPaths: settlement.outputPaths,
} }
yield* toolHooks.runAfter(afterEvent) yield* toolHooks.runAfter(afterEvent)
const finalOutput = afterEvent.output
? yield* resources.bound({
sessionID: input.sessionID,
callID: input.call.id,
output: afterEvent.output,
propagateTruncation,
})
: undefined
const outputPaths = [...new Set([...(afterEvent.outputPaths ?? []), ...(finalOutput?.outputPaths ?? [])])]
return { return {
result: afterEvent.result, result:
...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}), finalOutput !== undefined && afterEvent.result === initialResult && !("result" in pending)
...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}), ? ToolOutput.toResultValue(finalOutput.output)
: afterEvent.result,
...(finalOutput !== undefined ? { output: finalOutput.output } : {}),
...(outputPaths.length > 0 ? { outputPaths } : {}),
...(settlement.error !== undefined ? { error: settlement.error } : {}), ...(settlement.error !== undefined ? { error: settlement.error } : {}),
} }
}) })
+2
View File
@@ -148,6 +148,8 @@ export const Plugin = {
input: Input, input: Input,
output: Output, output: Output,
structured: StructuredOutput, structured: StructuredOutput,
codeModeOutput: "output",
contentTruncation: true,
toStructuredOutput: ({ output }) => ({ toStructuredOutput: ({ output }) => ({
truncated: output.truncated, truncated: output.truncated,
...(output.exit === undefined ? {} : { exit: output.exit }), ...(output.exit === undefined ? {} : { exit: output.exit }),
+16
View File
@@ -22,6 +22,13 @@ export const Output = Schema.Struct({
output: Schema.String, output: Schema.String,
}) })
const StructuredOutput = Schema.Struct({
name: Schema.String,
directory: Schema.String,
bytes: Schema.Number,
truncated: Schema.Boolean,
})
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 +73,15 @@ export const Plugin = {
description, description,
input: Input, input: Input,
output: Output, output: Output,
structured: StructuredOutput,
codeModeOutput: "output",
contentTruncation: true,
toStructuredOutput: ({ output }) => ({
name: output.name,
directory: output.directory,
bytes: Buffer.byteLength(output.output, "utf-8"),
truncated: false,
}),
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* () {
+70
View File
@@ -0,0 +1,70 @@
export * as ToolStructured from "./structured"
import { formatPatch, parsePatch } from "diff"
import { ToolOutputStore } from "../tool-output-store"
export function fit<A>(project: (maxStringBytes: number) => A) {
const full = project(ToolOutputStore.MAX_STRUCTURED_BYTES)
if (Buffer.byteLength(JSON.stringify(full), "utf-8") <= ToolOutputStore.MAX_STRUCTURED_BYTES) return full
let minimum = 0
let maximum = ToolOutputStore.MAX_STRUCTURED_BYTES
let result = project(0)
while (minimum <= maximum) {
const middle = Math.floor((minimum + maximum) / 2)
const candidate = project(middle)
if (Buffer.byteLength(JSON.stringify(candidate), "utf-8") <= ToolOutputStore.MAX_STRUCTURED_BYTES) {
result = candidate
minimum = middle + 1
continue
}
maximum = middle - 1
}
return result
}
export function truncate(input: string, maximumBytes: number) {
if (Buffer.byteLength(input, "utf-8") <= maximumBytes) return input
const marker = " ... truncated ..."
const markerBytes = Buffer.byteLength(marker, "utf-8")
const available = Math.max(0, maximumBytes - markerBytes)
let bytes = 0
let end = 0
for (const char of input) {
const size = Buffer.byteLength(char, "utf-8")
if (bytes + size > available) break
bytes += size
end += char.length
}
return input.slice(0, end).replace(/\r?\n$/, "") + (maximumBytes >= markerBytes ? marker : "")
}
export function patch(input: string, maximumBytes: number) {
if (Buffer.byteLength(input, "utf-8") <= maximumBytes) return input
const parsed = parsePatch(input)[0]
const hunk = parsed?.hunks[0]
if (!parsed || !hunk) return truncate(input, maximumBytes)
const changedLines = parsed.hunks.flatMap((item) => item.lines).filter((line) => /^[+-]/.test(line))
const removed = changedLines.filter((line) => line.startsWith("-"))
const added = changedLines.filter((line) => line.startsWith("+"))
const changed = [removed[0], added[0]]
.filter((line) => line !== undefined)
.map((line) => line[0] + truncate(line.slice(1), Math.max(0, Math.floor(maximumBytes / 2) - 1)))
const lines = [
...changed,
...(removed.length > 1 ? [`-... ${removed.length - 1} removed lines omitted ...`] : []),
...(added.length > 1 ? [`+... ${added.length - 1} added lines omitted ...`] : []),
]
return formatPatch({
...parsed,
hunks: [
{
oldStart: hunk.oldStart,
oldLines: lines.filter((line) => line.startsWith("-") || line.startsWith(" ")).length,
newStart: hunk.newStart,
newLines: lines.filter((line) => line.startsWith("+") || line.startsWith(" ")).length,
lines,
},
],
})
}
+15
View File
@@ -32,6 +32,13 @@ export const Output = Schema.Struct({
output: Schema.String, output: Schema.String,
}) })
const StructuredOutput = Schema.Struct({
sessionID: Output.fields.sessionID,
status: Output.fields.status,
bytes: Schema.Number,
truncated: Schema.Boolean,
})
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.",
"Foreground (default) runs the subagent to completion and returns its final response.", "Foreground (default) runs the subagent to completion and returns its final response.",
@@ -115,6 +122,14 @@ export const Plugin = {
description, description,
input: Input, input: Input,
output: Output, output: Output,
structured: StructuredOutput,
contentTruncation: true,
toStructuredOutput: ({ output }) => ({
sessionID: output.sessionID,
status: output.status,
bytes: Buffer.byteLength(output.output, "utf-8"),
truncated: false,
}),
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* () {
+18
View File
@@ -38,6 +38,14 @@ const Output = Schema.Struct({
output: Schema.String, output: Schema.String,
}) })
const StructuredOutput = Schema.Struct({
url: Schema.String,
contentType: Schema.String,
format: Input.fields.format,
bytes: Schema.Number,
truncated: Schema.Boolean,
})
type Format = (typeof Input.Type)["format"] type Format = (typeof Input.Type)["format"]
const acceptHeader = (format: Format) => { const acceptHeader = (format: Format) => {
@@ -126,6 +134,16 @@ export const Plugin = {
description, description,
input: Input, input: Input,
output: Output, output: Output,
structured: StructuredOutput,
codeModeOutput: "output",
contentTruncation: true,
toStructuredOutput: ({ input, output }) => ({
url: output.url,
contentType: output.contentType,
format: input.format,
bytes: Buffer.byteLength(output.output, "utf-8"),
truncated: false,
}),
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* () {
+14
View File
@@ -187,6 +187,12 @@ const Output = Schema.Struct({
text: Schema.String, text: Schema.String,
}) })
const StructuredOutput = Schema.Struct({
provider: Provider,
bytes: Schema.Number,
truncated: Schema.Boolean,
})
export const Plugin = { export const Plugin = {
id: "opencode.tool.websearch", id: "opencode.tool.websearch",
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
@@ -202,6 +208,14 @@ export const Plugin = {
description, description,
input: Input, input: Input,
output: Output, output: Output,
structured: StructuredOutput,
codeModeOutput: "output",
contentTruncation: true,
toStructuredOutput: ({ output }) => ({
provider: output.provider,
bytes: Buffer.byteLength(output.text, "utf-8"),
truncated: false,
}),
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)
@@ -12,11 +12,15 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { RelativePath } from "@opencode-ai/core/schema" import { RelativePath } from "@opencode-ai/core/schema"
import { Snapshot } from "@opencode-ai/core/snapshot" import { Snapshot } from "@opencode-ai/core/snapshot"
import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event" import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
const sessionID = SessionV2.ID.make("ses_tool_event_test") const sessionID = SessionV2.ID.make("ses_tool_event_test")
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
const outputs: Pick<ToolOutputStore.Interface, "bound"> = {
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
}
const capture = (providerMetadataKey = "anthropic") => { const capture = (providerMetadataKey = "anthropic", outputStore = outputs) => {
const published: Array<{ readonly type: string; readonly data: unknown }> = [] const published: Array<{ readonly type: string; readonly data: unknown }> = []
const events: Pick<EventV2.Interface, "publish"> = { const events: Pick<EventV2.Interface, "publish"> = {
publish: (definition, data) => publish: (definition, data) =>
@@ -33,7 +37,7 @@ const capture = (providerMetadataKey = "anthropic") => {
} }
return { return {
published, published,
publisher: createLLMEventPublisher(events, { publisher: createLLMEventPublisher(events, outputStore, {
sessionID, sessionID,
agent: AgentV2.ID.make("build"), agent: AgentV2.ID.make("build"),
model: { model: {
@@ -92,6 +96,71 @@ test("provider-executed success retains its raw provider result", async () => {
expect(success?.data).toHaveProperty("result") expect(success?.data).toHaveProperty("result")
}) })
test("provider-executed success bounds output before durable publication", async () => {
const guarded: ToolOutputStore.BoundInput[] = []
const outputStore: Pick<ToolOutputStore.Interface, "bound"> = {
bound: (input) =>
Effect.sync(() => guarded.push(input)).pipe(
Effect.as({
output: {
structured: { _truncated: true, _bytes: 20_000, _outputPath: "/managed/provider" },
content: [{ type: "text" as const, text: "bounded provider output" }],
},
outputPaths: ["/managed/provider"],
}),
),
}
const { published, publisher } = capture("anthropic", outputStore)
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
expect(guarded).toHaveLength(1)
expect(published.find((event) => event.type === "session.tool.success.1")?.data).toMatchObject({
structured: { _truncated: true, _bytes: 20_000, _outputPath: "/managed/provider" },
content: [{ type: "text", text: "bounded provider output" }],
result: result.result,
})
})
test("normalizes structured output to its durable record shape before bounding", async () => {
const guarded: ToolOutputStore.BoundInput[] = []
const outputStore: Pick<ToolOutputStore.Interface, "bound"> = {
bound: (input) => Effect.sync(() => guarded.push(input)).pipe(Effect.as({ output: input.output, outputPaths: [] })),
}
const { publisher } = capture("anthropic", outputStore)
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(
publisher.publish(
LLMEvent.toolResult({
...result,
output: { structured: "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES - 2), content: [] },
}),
),
)
expect(guarded[0]?.output.structured).toEqual({
value: "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES - 2),
})
})
test("a bounding failure leaves the tool eligible for durable failure", async () => {
const storage = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
const outputStore: Pick<ToolOutputStore.Interface, "bound"> = {
bound: () => Effect.fail(storage),
}
const { published, publisher } = capture("anthropic", outputStore)
await Effect.runPromise(publisher.publish(call))
const exit = await Effect.runPromiseExit(publisher.publish(result))
expect(exit._tag).toBe("Failure")
await Effect.runPromise(publisher.failUnsettledTools({ type: "unknown", message: storage.message }))
expect(published.some((event) => event.type === "session.tool.success.1")).toBe(false)
expect(published.find((event) => event.type === "session.tool.failed.1")?.data).toMatchObject({
callID: call.id,
error: { type: "unknown", message: storage.message },
})
})
test("provider metadata is flattened using the route key", async () => { test("provider metadata is flattened using the route key", async () => {
const { published, publisher } = capture() const { published, publisher } = capture()
await Effect.runPromise( await Effect.runPromise(
@@ -3,14 +3,18 @@ import { Tool } from "@opencode-ai/core/tool/tool"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import type { PermissionV2 } from "@opencode-ai/core/permission" import type { PermissionV2 } from "@opencode-ai/core/permission"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { Image } from "@opencode-ai/core/image" import { Image } from "@opencode-ai/core/image"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { executeTool, settleTool, toolDefinitions } from "./lib/tool" import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect" import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir"
const bounds: ToolOutputStore.BoundInput[] = [] const bounds: ToolOutputStore.BoundInput[] = []
const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") }) const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
@@ -52,6 +56,7 @@ const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [
[Image.node, imageStore], [Image.node, imageStore],
]) ])
const it = testEffect(registryLayer) const it = testEffect(registryLayer)
const live = testEffect(Layer.empty)
const identity = { const identity = {
agent: AgentV2.ID.make("build"), agent: AgentV2.ID.make("build"),
messageID: SessionMessage.ID.make("msg_registry"), messageID: SessionMessage.ID.make("msg_registry"),
@@ -84,6 +89,129 @@ const constant = (text: string) =>
}) })
describe("ToolRegistry", () => { describe("ToolRegistry", () => {
live.live("bounds structured-only output before deriving the model result", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
const layer = AppNodeBuilder.build(ToolRegistry.node, [
[Global.node, Global.layerWith({ data: tmp.path })],
[Image.node, imageStore],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
])
return Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const text = "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES)
yield* service.register(
{
structured_only: Tool.make({
description: "Return structured output",
input: Schema.Struct({}),
output: Schema.Struct({ text: Schema.String }),
execute: () => Effect.succeed({ text }),
}),
},
{ codemode: false },
)
const settled = yield* settleTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-structured-only", name: "structured_only", input: {} },
})
const encoded = JSON.stringify({ text })
expect(settled.result).toEqual({ type: "text", value: encoded })
expect(settled.outputPaths).toHaveLength(1)
expect(settled.output).toEqual({
structured: {
_truncated: true,
_bytes: Buffer.byteLength(encoded),
_outputPath: settled.outputPaths?.[0],
},
content: [{ type: "text", text: encoded }],
})
}).pipe(Effect.provide(layer))
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
live.live("bounds output replaced by an execute-after hook", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
const layer = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolHooks.node]), [
[Global.node, Global.layerWith({ data: tmp.path })],
[Image.node, imageStore],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
])
return Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const hooks = yield* ToolHooks.Service
const text = "y".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES)
yield* hooks.hook.after((event) => {
event.result = { type: "text", value: "hook result" }
event.output = { structured: { text }, content: [] }
})
yield* service.register({ hooked: make() }, { codemode: false })
const settled = yield* settleTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-hooked", name: "hooked", input: { text: "original" } },
})
const encoded = JSON.stringify({ text })
expect(settled.result).toEqual({ type: "text", value: "hook result" })
expect(settled.outputPaths).toHaveLength(1)
expect(settled.output).toEqual({
structured: {
_truncated: true,
_bytes: Buffer.byteLength(encoded),
_outputPath: settled.outputPaths?.[0],
},
content: [{ type: "text", text: encoded }],
})
}).pipe(Effect.provide(layer))
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
live.live("allows execute-after hooks to redact output before retention", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
const layer = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolHooks.node]), [
[Global.node, Global.layerWith({ data: tmp.path })],
[Image.node, imageStore],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
])
return Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const hooks = yield* ToolHooks.Service
const text = "sensitive".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES)
let observed: unknown
yield* hooks.hook.after((event) => {
observed = event.output?.structured
event.output = { structured: { redacted: true }, content: [{ type: "text", text: "redacted" }] }
})
yield* service.register({ secret: constant(text) }, { codemode: false })
const settled = yield* settleTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-secret", name: "secret", input: { text: "ignored" } },
})
expect(observed).toEqual({ text })
expect(settled).toEqual({
result: { type: "text", value: "redacted" },
output: { structured: { redacted: true }, content: [{ type: "text", text: "redacted" }] },
})
}).pipe(Effect.provide(layer))
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.effect("rejects invalid dotted namespaces", () => it.effect("rejects invalid dotted namespaces", () =>
Effect.gen(function* () { Effect.gen(function* () {
const service = yield* ToolRegistry.Service const service = yield* ToolRegistry.Service
@@ -113,12 +241,15 @@ describe("ToolRegistry", () => {
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () => it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () { Effect.gen(function* () {
const service = yield* ToolRegistry.Service const service = yield* ToolRegistry.Service
yield* service.register({ yield* service.register(
question: make(), {
bash: make(), question: make(),
edit: make("edit"), bash: make(),
write: make("edit"), edit: make("edit"),
}, { codemode: false }) write: make("edit"),
},
{ codemode: false },
)
const names = (permissions: PermissionV2.Ruleset) => const names = (permissions: PermissionV2.Ruleset) =>
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name))) toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
@@ -191,14 +322,17 @@ describe("ToolRegistry", () => {
it.effect("returns model errors without swallowing interruption or defects", () => it.effect("returns model errors without swallowing interruption or defects", () =>
Effect.gen(function* () { Effect.gen(function* () {
const service = yield* ToolRegistry.Service const service = yield* ToolRegistry.Service
yield* service.register({ yield* service.register(
failed: Tool.make({ {
description: "Failed", failed: Tool.make({
input: Schema.Struct({}), description: "Failed",
output: Schema.Struct({ ok: Schema.Boolean }), input: Schema.Struct({}),
execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })), output: Schema.Struct({ ok: Schema.Boolean }),
}), execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
}, { codemode: false }) }),
},
{ codemode: false },
)
expect( expect(
yield* executeTool(service, { yield* executeTool(service, {
sessionID, sessionID,
@@ -214,14 +348,17 @@ describe("ToolRegistry", () => {
}), }),
).toEqual({ type: "error", value: "Unknown tool: missing" }) ).toEqual({ type: "error", value: "Unknown tool: missing" })
yield* service.register({ yield* service.register(
defect: Tool.make({ {
description: "Defect", defect: Tool.make({
input: Schema.Struct({}), description: "Defect",
output: Schema.Struct({}), input: Schema.Struct({}),
execute: () => Effect.die("unexpected executor defect"), output: Schema.Struct({}),
}), execute: () => Effect.die("unexpected executor defect"),
}, { codemode: false }) }),
},
{ codemode: false },
)
expect( expect(
yield* service.materialize().pipe( yield* service.materialize().pipe(
Effect.flatMap((materialized) => Effect.flatMap((materialized) =>
@@ -264,22 +401,23 @@ describe("ToolRegistry", () => {
Effect.gen(function* () { Effect.gen(function* () {
const service = yield* ToolRegistry.Service const service = yield* ToolRegistry.Service
const contexts: Tool.Context[] = [] const contexts: Tool.Context[] = []
yield* service.register({ yield* service.register(
context: Tool.make({ {
description: "Context", context: Tool.make({
input: Schema.Struct({}), description: "Context",
output: Schema.Struct({ ok: Schema.Boolean }), input: Schema.Struct({}),
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })), output: Schema.Struct({ ok: Schema.Boolean }),
}), execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
}, { codemode: false }) }),
},
{ codemode: false },
)
yield* executeTool(service, { yield* executeTool(service, {
sessionID, sessionID,
...identity, ...identity,
call: { type: "tool-call", id: "call-context", name: "context", input: {} }, call: { type: "tool-call", id: "call-context", name: "context", input: {} },
}) })
expect(contexts).toEqual([ expect(contexts).toEqual([{ sessionID, ...identity, callID: "call-context", progress: expect.any(Function) }])
{ sessionID, ...identity, callID: "call-context", progress: expect.any(Function) },
])
}), }),
) )
@@ -306,20 +444,23 @@ describe("ToolRegistry", () => {
it.effect("normalizes image tool output at settlement and drops unresizable images", () => it.effect("normalizes image tool output at settlement and drops unresizable images", () =>
Effect.gen(function* () { Effect.gen(function* () {
const service = yield* ToolRegistry.Service const service = yield* ToolRegistry.Service
yield* service.register({ yield* service.register(
snapshot: Tool.make({ {
description: "Return images", snapshot: Tool.make({
input: Schema.Struct({ text: Schema.String }), description: "Return images",
output: Schema.Struct({ text: Schema.String }), input: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ text }), output: Schema.Struct({ text: Schema.String }),
toModelOutput: ({ output }) => [ execute: ({ text }) => Effect.succeed({ text }),
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" }, toModelOutput: ({ output }) => [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" }, { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" }, { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
{ type: "text", text: output.text }, { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" },
], { type: "text", text: output.text },
}), ],
}, { codemode: false }) }),
},
{ codemode: false },
)
const settlement = yield* settleTool(service, call("snapshot")) const settlement = yield* settleTool(service, call("snapshot"))
expect(settlement.output?.content).toEqual([ expect(settlement.output?.content).toEqual([
@@ -334,23 +475,26 @@ describe("ToolRegistry", () => {
it.effect("normalizes image progress content before it is published", () => it.effect("normalizes image progress content before it is published", () =>
Effect.gen(function* () { Effect.gen(function* () {
const service = yield* ToolRegistry.Service const service = yield* ToolRegistry.Service
yield* service.register({ yield* service.register(
progressive: Tool.make({ {
description: "Emit image progress", progressive: Tool.make({
input: Schema.Struct({ text: Schema.String }), description: "Emit image progress",
output: Schema.Struct({ text: Schema.String }), input: Schema.Struct({ text: Schema.String }),
execute: ({ text }, context) => output: Schema.Struct({ text: Schema.String }),
context execute: ({ text }, context) =>
.progress({ context
structured: { stage: "capture" }, .progress({
content: [ structured: { stage: "capture" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" }, content: [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" }, { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
], { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
}) ],
.pipe(Effect.as({ text })), })
}), .pipe(Effect.as({ text })),
}, { codemode: false }) }),
},
{ codemode: false },
)
const updates: ToolRegistry.Progress[] = [] const updates: ToolRegistry.Progress[] = []
yield* settleTool(service, { yield* settleTool(service, {
@@ -382,15 +526,18 @@ describe("ToolRegistry", () => {
encode: SchemaGetter.transform((value) => value === "yes"), encode: SchemaGetter.transform((value) => value === "yes"),
}), }),
) )
yield* service.register({ yield* service.register(
transformed: Tool.make({ {
description: "Transform values", transformed: Tool.make({
input: Schema.Struct({ value: Transformed }), description: "Transform values",
output: Schema.Struct({ value: Transformed }), input: Schema.Struct({ value: Transformed }),
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })), output: Schema.Struct({ value: Transformed }),
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }], execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
}), toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
}, { codemode: false }) }),
},
{ codemode: false },
)
expect( expect(
yield* executeTool(service, { yield* executeTool(service, {
@@ -409,25 +556,28 @@ describe("ToolRegistry", () => {
).toMatchObject({ type: "error", value: expect.stringContaining("Invalid tool input") }) ).toMatchObject({ type: "error", value: expect.stringContaining("Invalid tool input") })
expect(executed).toEqual(["yes"]) expect(executed).toEqual(["yes"])
yield* service.register({ yield* service.register(
invalid_output: Tool.make({ {
description: "Return invalid output", invalid_output: Tool.make({
input: Schema.Struct({}), description: "Return invalid output",
output: Schema.Struct({ input: Schema.Struct({}),
value: Schema.Boolean.pipe( output: Schema.Struct({
Schema.decodeTo(Schema.String, { value: Schema.Boolean.pipe(
decode: SchemaGetter.transform((value) => String(value)), Schema.decodeTo(Schema.String, {
encode: SchemaGetter.transformOrFail((value) => decode: SchemaGetter.transform((value) => String(value)),
value === "valid" encode: SchemaGetter.transformOrFail((value) =>
? Effect.succeed(true) value === "valid"
: Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })), ? Effect.succeed(true)
), : Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })),
}), ),
), }),
),
}),
execute: () => Effect.succeed({ value: "invalid" }),
}), }),
execute: () => Effect.succeed({ value: "invalid" }), },
}), { codemode: false },
}, { codemode: false }) )
expect( expect(
yield* executeTool(service, { yield* executeTool(service, {
sessionID, sessionID,
+166 -105
View File
@@ -239,43 +239,46 @@ const permission = Layer.succeed(
) )
const echo = Layer.effectDiscard( const echo = Layer.effectDiscard(
ToolRegistry.Service.use((registry) => ToolRegistry.Service.use((registry) =>
registry.register({ registry.register(
echo: Tool.make({ {
description: "Echo text", echo: Tool.make({
input: Schema.Struct({ text: Schema.String }), description: "Echo text",
output: Schema.Struct({ text: Schema.String }), input: Schema.Struct({ text: Schema.String }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }], output: Schema.Struct({ text: Schema.String }),
execute: ({ text }, context) => toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
Effect.gen(function* () { execute: ({ text }, context) =>
authorizations.push(context) Effect.gen(function* () {
executions.push(text) authorizations.push(context)
activeToolExecutions++ executions.push(text)
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions) activeToolExecutions++
if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) { maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
yield* Deferred.succeed(toolExecutionsStarted, undefined) if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) {
} yield* Deferred.succeed(toolExecutionsStarted, undefined)
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate) }
return { text } if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))), return { text }
}), }).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
defect: Tool.make({ }),
description: "Fail unexpectedly", defect: Tool.make({
input: Schema.Struct({}), description: "Fail unexpectedly",
output: Schema.Struct({}), input: Schema.Struct({}),
execute: () => output: Schema.Struct({}),
(toolExecutionGate ? Deferred.await(toolExecutionGate) : Effect.void).pipe( execute: () =>
Effect.andThen(Effect.die("unexpected tool defect")), (toolExecutionGate ? Deferred.await(toolExecutionGate) : Effect.void).pipe(
), Effect.andThen(Effect.die("unexpected tool defect")),
}), ),
// BigInt output with no model content forces ToolOutputStore.bound onto its }),
// JSON.stringify encode path, which fails with a typed StorageError. // BigInt output with no model content forces ToolOutputStore.bound onto its
storefail: Tool.make({ // JSON.stringify encode path, which fails with a typed StorageError.
description: "Produce output that cannot be persisted", storefail: Tool.make({
input: Schema.Struct({}), description: "Produce output that cannot be persisted",
output: Schema.Any, input: Schema.Struct({}),
execute: () => Effect.succeed({ big: 1n }), output: Schema.Any,
}), execute: () => Effect.succeed({ big: 1n }),
}, { codemode: false }), }),
},
{ codemode: false },
),
), ),
) )
const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] }) const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
@@ -840,19 +843,23 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup const session = yield* setup
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const contexts: Tool.Context[] = [] const contexts: Tool.Context[] = []
yield* registry.register({ const progress = "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES)
location_context: Tool.make({ yield* registry.register(
description: "Read application context", {
input: Schema.Struct({ query: Schema.String }), location_context: Tool.make({
output: Schema.Struct({ answer: Schema.String }), description: "Read application context",
execute: ({ query }, context) => input: Schema.Struct({ query: Schema.String }),
Effect.gen(function* () { output: Schema.Struct({ answer: Schema.String }),
contexts.push(context) execute: ({ query }, context) =>
yield* context.progress({ structured: { phase: "reading" } }) Effect.gen(function* () {
return { answer: query.toUpperCase() } contexts.push(context)
}), yield* context.progress({ structured: { phase: progress } })
}), return { answer: query.toUpperCase() }
}, { codemode: false }) }),
}),
},
{ codemode: false },
)
yield* admit(session, "Use application context") yield* admit(session, "Use application context")
responses = [reply.tool("call-location", "location_context", { query: "hello" }), []] responses = [reply.tool("call-location", "location_context", { query: "hello" }), []]
const events = yield* EventV2.Service const events = yield* EventV2.Service
@@ -875,7 +882,13 @@ describe("SessionRunnerLLM", () => {
progress: expect.any(Function), progress: expect.any(Function),
}, },
]) ])
expect(Array.from(yield* Fiber.join(progressFiber))[0]?.data.structured).toEqual({ phase: "reading" }) const update = Array.from(yield* Fiber.join(progressFiber))[0]?.data
expect(update?.structured).toMatchObject({
_truncated: true,
_bytes: Buffer.byteLength(JSON.stringify({ phase: progress })),
_outputPath: expect.any(String),
})
expect(update?.content).toEqual([{ type: "text", text: JSON.stringify({ phase: progress }) }])
expect(yield* session.context(sessionID)).toMatchObject([ expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Use application context" }, { type: "user", text: "Use application context" },
{ {
@@ -899,14 +912,17 @@ describe("SessionRunnerLLM", () => {
const scope = yield* Scope.make() const scope = yield* Scope.make()
const executions: string[] = [] const executions: string[] = []
yield* registry yield* registry
.register({ .register(
reloaded: Tool.make({ {
description: "Record the advertised tool", reloaded: Tool.make({
input: Schema.Struct({}), description: "Record the advertised tool",
output: Schema.Struct({ value: Schema.String }), input: Schema.Struct({}),
execute: () => Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ value: "advertised" })), output: Schema.Struct({ value: Schema.String }),
}), execute: () => Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ value: "advertised" })),
}, { codemode: false }) }),
},
{ codemode: false },
)
.pipe(Scope.provide(scope)) .pipe(Scope.provide(scope))
yield* admit(session, "Use the reloaded tool") yield* admit(session, "Use the reloaded tool")
responses = [ responses = [
@@ -924,14 +940,17 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild) const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted) yield* Deferred.await(streamStarted)
yield* Scope.close(scope, Exit.void) yield* Scope.close(scope, Exit.void)
yield* registry.register({ yield* registry.register(
reloaded: Tool.make({ {
description: "Record the replacement tool", reloaded: Tool.make({
input: Schema.Struct({}), description: "Record the replacement tool",
output: Schema.Struct({ value: Schema.String }), input: Schema.Struct({}),
execute: () => Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ value: "replacement" })), output: Schema.Struct({ value: Schema.String }),
}), execute: () => Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ value: "replacement" })),
}, { codemode: false }) }),
},
{ codemode: false },
)
yield* Deferred.succeed(streamGate, undefined) yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(run) yield* Fiber.join(run)
@@ -3367,17 +3386,20 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* setup const session = yield* setup
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
yield* registry.register({ yield* registry.register(
blocked: Tool.make({ {
description: "Fail because policy blocked execution", blocked: Tool.make({
input: Schema.Struct({}), description: "Fail because policy blocked execution",
output: Schema.Struct({}), input: Schema.Struct({}),
execute: () => output: Schema.Struct({}),
Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "blocked", resources: ["*"] })).pipe( execute: () =>
Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })), Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "blocked", resources: ["*"] })).pipe(
), Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })),
}), ),
}, { codemode: false }) }),
},
{ codemode: false },
)
yield* admit(session, "Call blocked") yield* admit(session, "Call blocked")
responses = [reply.tool("call-blocked", "blocked", {}), reply.stop()] responses = [reply.tool("call-blocked", "blocked", {}), reply.stop()]
@@ -3402,14 +3424,17 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* setup const session = yield* setup
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
yield* registry.register({ yield* registry.register(
declined: Tool.make({ {
description: "Fail because the user declined approval", declined: Tool.make({
input: Schema.Struct({}), description: "Fail because the user declined approval",
output: Schema.Struct({}), input: Schema.Struct({}),
execute: () => Effect.die(new PermissionV2.DeclinedError()), output: Schema.Struct({}),
}), execute: () => Effect.die(new PermissionV2.DeclinedError()),
}, { codemode: false }) }),
},
{ codemode: false },
)
yield* admit(session, "Call declined") yield* admit(session, "Call declined")
response = reply.tool("call-declined", "declined", {}) response = reply.tool("call-declined", "declined", {})
@@ -3439,17 +3464,20 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* setup const session = yield* setup
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
yield* registry.register({ yield* registry.register(
corrected: Tool.make({ {
description: "Fail with user correction feedback", corrected: Tool.make({
input: Schema.Struct({}), description: "Fail with user correction feedback",
output: Schema.Struct({}), input: Schema.Struct({}),
execute: () => output: Schema.Struct({}),
Effect.fail(new PermissionV2.CorrectedError({ feedback: "Use another tool" })).pipe( execute: () =>
Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })), Effect.fail(new PermissionV2.CorrectedError({ feedback: "Use another tool" })).pipe(
), Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })),
}), ),
}, { codemode: false }) }),
},
{ codemode: false },
)
yield* admit(session, "Call corrected") yield* admit(session, "Call corrected")
responses = [reply.tool("call-corrected", "corrected", {}), reply.stop()] responses = [reply.tool("call-corrected", "corrected", {}), reply.stop()]
@@ -3505,6 +3533,36 @@ describe("SessionRunnerLLM", () => {
}), }),
) )
it.effect("does not hide output persistence failure behind another concurrent tool defect", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Call defect and storefail")
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-defect", name: "defect", input: {} }),
LLMEvent.toolCall({ id: "call-storefail", name: "storefail", input: {} }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[],
]
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Call defect and storefail" },
{
type: "assistant",
finish: "error",
error: { type: "unknown", message: expect.stringContaining("Failed to encode tool output") },
},
])
}),
)
it.effect("returns configured permission denials to the model and continues", () => it.effect("returns configured permission denials to the model and continues", () =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* setup const session = yield* setup
@@ -3547,14 +3605,17 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* setup const session = yield* setup
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
yield* registry.register({ yield* registry.register(
question: Tool.make({ {
description: "Ask the user", question: Tool.make({
input: Schema.Struct({}), description: "Ask the user",
output: Schema.Struct({}), input: Schema.Struct({}),
execute: () => Effect.die(new QuestionTool.CancelledError()), output: Schema.Struct({}),
}), execute: () => Effect.die(new QuestionTool.CancelledError()),
}, { codemode: false }) }),
},
{ codemode: false },
)
yield* admit(session, "Ask then stop") yield* admit(session, "Ask then stop")
responses = [reply.tool("call-question", "question", {}), []] responses = [reply.tool("call-question", "question", {}), []]
+44 -1
View File
@@ -2,7 +2,8 @@ import fs from "fs/promises"
import path from "path" import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer, Schema } from "effect"
import { parsePatch } from "diff"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation" import { FileMutation } from "@opencode-ai/core/file-mutation"
@@ -174,6 +175,48 @@ describe("EditTool", () => {
), ),
) )
it.live("retains a bounded patch for large edits", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "large.txt")
const before = "x".repeat(20_000)
const after = "y".repeat(20_000)
return Effect.promise(() => fs.writeFile(target, before)).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const settled = yield* settleTool(
registry,
call({ path: "large.txt", oldString: before, newString: after }),
)
const structured = Schema.decodeUnknownSync(EditTool.Output)(settled.output?.structured)
expect(Buffer.byteLength(JSON.stringify(structured))).toBeLessThanOrEqual(
ToolOutputStore.MAX_STRUCTURED_BYTES,
)
const hunk = parsePatch(structured.files[0]?.patch ?? "")[0]?.hunks[0]
expect(hunk?.lines.some((line) => line.startsWith("-"))).toBe(true)
expect(hunk?.lines.some((line) => line.startsWith("+"))).toBe(true)
expect(hunk).toMatchObject({ oldLines: 1, newLines: 1 })
expect(structured).toMatchObject({
replacements: 1,
files: [
{
file: "large.txt",
patch: expect.stringContaining("... truncated ..."),
},
],
})
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("accepts an absolute file path inside the active Location", () => it.live("accepts an absolute file path inside the active Location", () =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
+67
View File
@@ -93,3 +93,70 @@ test("execute supports callable namespace tools", async () => {
}) })
expect(result.content).toEqual([{ type: "text", text: '[\n "admin",\n "created"\n]' }]) expect(result.content).toEqual([{ type: "text", text: '[\n "admin",\n "created"\n]' }])
}) })
test("execute exposes typed internal output instead of the persisted receipt", async () => {
const child = Tool.make({
description: "Fetch content",
input: Schema.Struct({}),
output: Schema.Struct({ content: Schema.String, bytes: Schema.Number }),
structured: Schema.Struct({ bytes: Schema.Number }),
codeModeOutput: "output",
toStructuredOutput: ({ output }) => ({ bytes: output.bytes }),
toModelOutput: ({ output }) => [{ type: "text", text: output.content }],
execute: () => Effect.succeed({ content: "full payload", bytes: 12 }),
})
const execute = ExecuteTool.create(new Map([["fetch", { tool: child, name: "fetch" }]]))
const result = await Effect.runPromise(
Tool.settle(
execute,
{
type: "tool-call",
id: "call_execute",
name: "execute",
input: { code: "return (await tools.fetch({})).content" },
},
{
sessionID: Session.ID.make("ses_execute"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_execute"),
callID: "call_execute",
progress: () => Effect.void,
},
),
)
expect(result.structured).toEqual({ toolCalls: [{ tool: "fetch", status: "completed" }] })
expect(result.content).toEqual([{ type: "text", text: "full payload" }])
})
test("execute keeps the structured projection unless a tool opts into internal output", async () => {
const child = Tool.make({
description: "Fetch content",
input: Schema.Struct({}),
output: Schema.Struct({ content: Schema.String, bytes: Schema.Number }),
structured: Schema.Struct({ bytes: Schema.Number }),
toStructuredOutput: ({ output }) => ({ bytes: output.bytes }),
execute: () => Effect.succeed({ content: "internal", bytes: 8 }),
})
const execute = ExecuteTool.create(new Map([["fetch", { tool: child, name: "fetch" }]]))
const result = await Effect.runPromise(
Tool.settle(
execute,
{
type: "tool-call",
id: "call_execute",
name: "execute",
input: { code: "return await tools.fetch({})" },
},
{
sessionID: Session.ID.make("ses_execute"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_execute"),
callID: "call_execute",
progress: () => Effect.void,
},
),
)
expect(result.content).toEqual([{ type: "text", text: '{\n "bytes": 8\n}' }])
})
+182 -10
View File
@@ -75,7 +75,14 @@ describe("ToolOutputStore", () => {
Effect.gen(function* () { Effect.gen(function* () {
const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) } const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) }
const result = yield* store.bound({ sessionID, callID: "call-json", output: { structured, content: [] } }) const result = yield* store.bound({ sessionID, callID: "call-json", output: { structured, content: [] } })
expect(result.output.structured).toEqual(structured) expect(result.output.structured).toEqual({
_truncated: true,
_bytes: Buffer.byteLength(JSON.stringify(structured)),
_outputPath: result.outputPaths[0],
})
expect(Buffer.byteLength(JSON.stringify(result.output.structured))).toBeLessThanOrEqual(
ToolOutputStore.MAX_STRUCTURED_BYTES,
)
expect(result.outputPaths).toHaveLength(1) expect(result.outputPaths).toHaveLength(1)
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured) expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
expect(result.output.content).toHaveLength(1) expect(result.output.content).toHaveLength(1)
@@ -83,6 +90,131 @@ describe("ToolOutputStore", () => {
), ),
) )
it.live("projects guarded structured-only output into the bounded content channel", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const structured = { text: "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES) }
const result = yield* store.bound({
sessionID,
callID: "call-structured-content",
output: { structured, content: [] },
})
expect(result.output.structured).toEqual({
_truncated: true,
_bytes: Buffer.byteLength(JSON.stringify(structured)),
_outputPath: result.outputPaths[0],
})
expect(result.output.content).toEqual([{ type: "text", text: JSON.stringify(structured) }])
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
}),
),
)
it.live("measures the structured boundary in UTF-8 bytes", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const structured = { text: "é".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES / 2) }
const encoded = JSON.stringify(structured)
expect(structured.text.length).toBeLessThan(ToolOutputStore.MAX_STRUCTURED_BYTES)
expect(Buffer.byteLength(encoded)).toBeGreaterThan(ToolOutputStore.MAX_STRUCTURED_BYTES)
const result = yield* store.bound({
sessionID,
callID: "call-structured-utf8",
output: { structured, content: [] },
})
expect(result.output.structured).toMatchObject({
_truncated: true,
_bytes: Buffer.byteLength(encoded),
})
}),
),
)
it.live("retains independent structured and contextual overflow", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const structured = { detail: "s".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES) }
const content = "c".repeat(ToolOutputStore.MAX_BYTES + 1)
const result = yield* store.bound({
sessionID,
callID: "call-two-channel-overflow",
output: { structured, content: [{ type: "text", text: content }] },
})
expect(result.outputPaths).toHaveLength(2)
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
expect(yield* fs.readFileString(result.outputPaths[1])).toBe(content)
}),
),
)
it.live("keeps structured output at the receipt boundary inline", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const empty = JSON.stringify({ text: "" })
const structured = { text: "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES - Buffer.byteLength(empty)) }
expect(Buffer.byteLength(JSON.stringify(structured))).toBe(ToolOutputStore.MAX_STRUCTURED_BYTES)
const result = yield* store.bound({
sessionID,
callID: "call-structured-boundary",
output: { structured, content: [] },
})
expect(result).toEqual({ output: { structured, content: [] }, outputPaths: [] })
}),
),
)
it.live("normalizes structured values to their durable JSON record", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const primitive = yield* store.bound({
sessionID,
callID: "call-primitive",
output: { structured: "value", content: [] },
})
const nonFinite = yield* store.bound({
sessionID,
callID: "call-non-finite",
output: { structured: { value: Number.NaN }, content: [] },
})
const omitted = yield* store.bound({
sessionID,
callID: "call-omitted",
output: { structured: undefined, content: [] },
})
expect(primitive.output.structured).toEqual({ value: "value" })
expect(nonFinite.output.structured).toEqual({ value: null })
expect(omitted.output.structured).toEqual({})
}),
),
)
it.live("measures primitive overflow after durable record normalization", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const value = "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES)
const encoded = JSON.stringify({ value })
const result = yield* store.bound({
sessionID,
callID: "call-primitive-overflow",
output: { structured: value, content: [] },
})
expect(result.output.structured).toEqual({
_truncated: true,
_bytes: Buffer.byteLength(encoded),
_outputPath: result.outputPaths[0],
})
expect(yield* fs.readFileString(result.outputPaths[0])).toBe(encoded)
}),
),
)
it.live("preserves native media and structured metadata without applying a settlement media limit", () => it.live("preserves native media and structured metadata without applying a settlement media limit", () =>
withStore(({ store }) => withStore(({ store }) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -131,15 +263,52 @@ describe("ToolOutputStore", () => {
), ),
) )
it.live("does not double-count structured data duplicated in projected text", () => it.live("marks projected receipt metadata truncated when bounding its contextual output", () =>
withStore(({ store }) => withStore(({ store }) =>
Effect.gen(function* () {
const result = yield* store.bound({
sessionID,
callID: "call-receipt",
propagateTruncation: true,
output: {
structured: { bytes: ToolOutputStore.MAX_BYTES + 1, truncated: false },
content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }],
},
})
expect(result.output.structured).toEqual({ bytes: ToolOutputStore.MAX_BYTES + 1, truncated: true })
}),
),
)
it.live("preserves ordinary truncated metadata when bounding contextual output", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const result = yield* store.bound({
sessionID,
callID: "call-ordinary-truncated",
output: {
structured: { truncated: false },
content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }],
},
})
expect(result.output.structured).toEqual({ truncated: false })
}),
),
)
it.live("bounds structured data duplicated in projected text independently", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () { Effect.gen(function* () {
const text = "x".repeat(30_000) const text = "x".repeat(30_000)
const output = { structured: { output: text }, content: [{ type: "text" as const, text }] } const output = { structured: { output: text }, content: [{ type: "text" as const, text }] }
expect(yield* store.bound({ sessionID, callID: "call-duplicated", output })).toEqual({ const result = yield* store.bound({ sessionID, callID: "call-duplicated", output })
output, expect(result.output.content).toEqual(output.content)
outputPaths: [], expect(result.output.structured).toEqual({
_truncated: true,
_bytes: Buffer.byteLength(JSON.stringify(output.structured)),
_outputPath: result.outputPaths[0],
}) })
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(output.structured)
}), }),
), ),
) )
@@ -162,14 +331,17 @@ describe("ToolOutputStore", () => {
), ),
) )
it.live("does not encode ignored structured metadata when projected content exists", () => it.live("rejects unencodable structured metadata even when projected content exists", () =>
withStore(({ store }) => withStore(({ store }) =>
Effect.gen(function* () { Effect.gen(function* () {
const output = { structured: { value: 1n }, content: [{ type: "text" as const, text: "readable text" }] } const output = { structured: { value: 1n }, content: [{ type: "text" as const, text: "readable text" }] }
expect(yield* store.bound({ sessionID, callID: "call-unencodable", output })).toEqual({ const exit = yield* store.bound({ sessionID, callID: "call-unencodable", output }).pipe(Effect.exit)
output, expect(Exit.isFailure(exit)).toBe(true)
outputPaths: [], if (Exit.isFailure(exit))
}) expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({
_tag: "ToolOutputStore.StorageError",
operation: "encode",
})
}), }),
), ),
) )
+57 -2
View File
@@ -1,7 +1,8 @@
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" import { describe, expect, test } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Layer } from "effect" import { Deferred, Effect, Exit, Fiber, Layer, Schema } from "effect"
import { createTwoFilesPatch, parsePatch } from "diff"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation" import { FileMutation } from "@opencode-ai/core/file-mutation"
@@ -14,6 +15,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { PatchTool } from "@opencode-ai/core/tool/patch" import { PatchTool } from "@opencode-ai/core/tool/patch"
import { ToolStructured } from "@opencode-ai/core/tool/structured"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
@@ -145,6 +147,22 @@ const exists = (target: string) =>
const it = testEffect(Layer.empty) const it = testEffect(Layer.empty)
describe("PatchTool", () => { describe("PatchTool", () => {
test("summarizes multi-line truncation without fabricating unchanged context", () => {
const before = Array.from({ length: 100 }, (_, index) => `old-${index}`).join("\n")
const after = Array.from({ length: 100 }, (_, index) => `new-${index}`).join("\n")
const summary = ToolStructured.patch(createTwoFilesPatch("large.txt", "large.txt", before, after), 500)
const hunk = parsePatch(summary)[0]?.hunks[0]
expect(hunk?.lines.some((line) => line.startsWith(" "))).toBe(false)
expect(hunk?.lines).toEqual([
"-old-0",
"+new-0",
"-... 99 removed lines omitted ...",
"+... 99 added lines omitted ...",
])
expect(hunk).toMatchObject({ oldLines: 2, newLines: 2 })
})
it.live("registers and sequentially applies add, update, and delete hunks", () => it.live("registers and sequentially applies add, update, and delete hunks", () =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
@@ -217,6 +235,43 @@ describe("PatchTool", () => {
), ),
) )
it.live("retains a bounded patch for large changes", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "large.txt")
const before = "x".repeat(20_000)
const after = "y".repeat(20_000)
return Effect.promise(() => fs.writeFile(target, `${before}\n`)).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const settled = yield* settleTool(
registry,
call(`*** Begin Patch\n*** Update File: large.txt\n@@\n-${before}\n+${after}\n*** End Patch`),
)
const structured = Schema.decodeUnknownSync(PatchTool.Output)(settled.output?.structured)
expect(Buffer.byteLength(JSON.stringify(structured))).toBeLessThanOrEqual(
ToolOutputStore.MAX_STRUCTURED_BYTES,
)
const hunk = parsePatch(structured.files[0]?.patch ?? "")[0]?.hunks[0]
expect(hunk?.lines.some((line) => line.startsWith("-"))).toBe(true)
expect(hunk?.lines.some((line) => line.startsWith("+"))).toBe(true)
expect(hunk).toMatchObject({ oldLines: 1, newLines: 1 })
expect(structured).toMatchObject({
applied: [{ type: "update", resource: "large.txt" }],
files: [{ file: "large.txt", patch: expect.stringContaining("... truncated ...") }],
})
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("rejects moves before applying any hunk", () => it.live("rejects moves before applying any hunk", () =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
+24 -1
View File
@@ -19,6 +19,7 @@ const assertions: PermissionV2.AssertInput[] = []
let captured: Form.CreateInput | undefined let captured: Form.CreateInput | undefined
let reject = false let reject = false
let deny = false let deny = false
let firstAnswer = "Build"
const capturedInput = () => captured const capturedInput = () => captured
const questionInput = { const questionInput = {
questions: [ questions: [
@@ -63,7 +64,7 @@ const form = Layer.succeed(
Effect.andThen( Effect.andThen(
Effect.sync( Effect.sync(
(): Form.TerminalState => (): Form.TerminalState =>
reject ? { status: "cancelled" } : { status: "answered", answer: { q0: "Build", q1: ["Dev"] } }, reject ? { status: "cancelled" } : { status: "answered", answer: { q0: firstAnswer, q1: ["Dev"] } },
), ),
), ),
), ),
@@ -122,6 +123,7 @@ describe("QuestionTool", () => {
captured = undefined captured = undefined
reject = false reject = false
deny = false deny = false
firstAnswer = "Build"
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const questions = [ const questions = [
{ {
@@ -200,6 +202,27 @@ describe("QuestionTool", () => {
}), }),
) )
it.effect("retains bounded answers for long custom responses", () =>
Effect.gen(function* () {
reject = false
deny = false
firstAnswer = "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES)
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question-large", name: "question", input: questionInput },
})
expect(Buffer.byteLength(JSON.stringify(settled.output?.structured))).toBeLessThanOrEqual(
ToolOutputStore.MAX_STRUCTURED_BYTES,
)
expect(settled.output?.structured).toMatchObject({
answers: [[expect.stringContaining("... truncated ...")]],
})
}).pipe(Effect.ensuring(Effect.sync(() => (firstAnswer = "Build")))),
)
it.effect("does not invent tool ownership metadata without a durable registry source", () => it.effect("does not invent tool ownership metadata without a durable registry source", () =>
Effect.gen(function* () { Effect.gen(function* () {
captured = undefined captured = undefined
@@ -100,6 +100,21 @@ describe("ReadToolFileSystem", () => {
}), }),
) )
it.effect("reports a shortened long line as truncated", () =>
Effect.gen(function* () {
const { fs, files, directory } = yield* fixture
const file = path.join(directory, "long-line.txt")
yield* files.writeFileString(file, "x".repeat(3_000))
const result = yield* ReadToolFileSystem.read(fs, file, "long-line.txt", { limit: 1 })
expect(result).toMatchObject({ type: "text-page", truncated: true })
if (!(result instanceof ReadToolFileSystem.TextPage)) throw new Error("expected text page")
expect(result.next).toBeUndefined()
expect(result.content).toEndWith("... (line truncated to 2000 chars)")
}),
)
it.effect("preserves the media ingestion limit message", () => it.effect("preserves the media ingestion limit message", () =>
Effect.gen(function* () { Effect.gen(function* () {
const { fs, files, directory } = yield* fixture const { fs, files, directory } = yield* fixture
+18 -10
View File
@@ -206,14 +206,14 @@ describe("ReadTool", () => {
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } }, call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
}), }),
).toEqual({ ).toEqual({
type: "json", type: "text",
value: { value: JSON.stringify({
uri: "file:///README.md", uri: "file:///README.md",
name: "README.md", name: "README.md",
content: "hello", content: "hello",
encoding: "utf8", encoding: "utf8",
mime: "text/plain", mime: "text/plain",
}, }),
}) })
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }]) expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }])
expect(readCalls).toEqual([ expect(readCalls).toEqual([
@@ -236,7 +236,7 @@ describe("ReadTool", () => {
...toolIdentity, ...toolIdentity,
call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } }, call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } },
}), }),
).toMatchObject({ type: "json" }) ).toMatchObject({ type: "text" })
expect(assertions).toMatchObject([ expect(assertions).toMatchObject([
{ {
sessionID, sessionID,
@@ -287,14 +287,15 @@ describe("ReadTool", () => {
call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } }, call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
}) })
expect(settled.output?.structured).toMatchObject({ expect(settled.output?.structured).toMatchObject({
type: "file",
uri: "file:///pixel.png", uri: "file:///pixel.png",
name: "pixel.png", name: "pixel.png",
mime: "image/png", mime: "image/png",
encoding: "base64", encoding: "base64",
// Image base64 is carried by the content file item only; structured is slimmed bytes: Buffer.byteLength(png, "base64"),
// so the original bytes are never persisted twice. truncated: false,
content: "",
}) })
expect(settled.output?.structured).not.toHaveProperty("content")
expect(settled.output?.content).toMatchObject([ expect(settled.output?.content).toMatchObject([
{ type: "text", text: "Image read successfully" }, { type: "text", text: "Image read successfully" },
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` }, { type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
@@ -626,7 +627,7 @@ describe("ReadTool", () => {
input: { path: "src", offset: 2, limit: 10 }, input: { path: "src", offset: 2, limit: 10 },
}, },
}), }),
).toEqual({ type: "json", value: { entries: [], truncated: false } }) ).toEqual({ type: "text", value: JSON.stringify({ entries: [], truncated: false }) })
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }]) expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
expect(listCalls).toEqual([{ offset: 2, limit: 10 }]) expect(listCalls).toEqual([{ offset: 2, limit: 10 }])
}), }),
@@ -692,8 +693,15 @@ describe("ReadTool", () => {
}, },
}), }),
).toEqual({ ).toEqual({
type: "json", type: "text",
value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 }, value: JSON.stringify({
type: "text-page",
content: "hello",
mime: "text/plain",
offset: 2,
truncated: true,
next: 3,
}),
}) })
expect(readCalls).toEqual([ expect(readCalls).toEqual([
{ input: AbsolutePath.make(path.join(process.cwd(), "large.txt")), page: { offset: 2, limit: 1 } }, { input: AbsolutePath.make(path.join(process.cwd(), "large.txt")), page: { offset: 2, limit: 1 } },
+2 -2
View File
@@ -86,8 +86,8 @@ 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 })
}), }),
) )
}), }),
+28 -1
View File
@@ -223,6 +223,30 @@ describe("ShellTool", () => {
), ),
) )
it.live("marks output truncated when generic settlement limits are lower than shell capture limits", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const settled = yield* settleTool(
registry,
call({ command: overflowCommand(ToolOutputStore.MAX_BYTES + 1_000) }),
)
expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: true })
expect(settled.output?.content[0]).toMatchObject({
type: "text",
text: expect.stringContaining("output truncated; full content saved to"),
})
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("resolves a relative workdir from the active Location", () => it.live("resolves a relative workdir from the active Location", () =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
@@ -439,7 +463,10 @@ describe("ShellTool", () => {
if (update.structured.truncated !== true) return if (update.structured.truncated !== true) return
const content = update.content[0] const content = update.content[0]
if (content?.type !== "text") return if (content?.type !== "text") return
if (content.text.indexOf("\n\n[output truncated; full output saved to:") !== ShellTool.MAX_CAPTURE_BYTES) if (
content.text.indexOf("\n\n[output truncated; full output saved to:") !==
ShellTool.MAX_CAPTURE_BYTES
)
return return
yield* Deferred.succeed(observed, update) yield* Deferred.succeed(observed, update)
yield* Effect.promise(() => fs.writeFile(releasePath, "")) yield* Effect.promise(() => fs.writeFile(releasePath, ""))
+8 -1
View File
@@ -121,7 +121,14 @@ describe("SkillTool", () => {
}), }),
).toMatchObject({ ).toMatchObject({
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,
bytes: Buffer.byteLength(SkillTool.toModelOutput(info, [reference])),
truncated: false,
},
},
}) })
expect(assertions).toMatchObject([ expect(assertions).toMatchObject([
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] }, { sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
+18 -4
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,12 @@ describe("SubagentTool", () => {
}, },
}) })
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText }) expect(settled.output?.structured).toMatchObject({
status: "completed",
bytes: Buffer.byteLength(childText),
truncated: false,
})
expect(settled.output?.structured).not.toHaveProperty("output")
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,7 +270,13 @@ describe("SubagentTool", () => {
}, },
}) })
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText }) expect(settled.result).toEqual({ type: "text", value: childText })
expect(settled.output?.structured).toMatchObject({
status: "completed",
bytes: Buffer.byteLength(childText),
truncated: false,
})
expect(settled.output?.structured).not.toHaveProperty("output")
const child = yield* sessions.get(outputSessionID(settled.output?.structured)) const child = yield* sessions.get(outputSessionID(settled.output?.structured))
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({
@@ -358,8 +370,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}`), bytes: expect.any(Number),
truncated: false,
}) })
expect(settled.output?.structured).not.toHaveProperty("output")
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"`)
+1 -1
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: { url, contentType: "text/plain", format: "text", bytes: 5, truncated: false },
content: [{ type: "text", text: "hello" }], content: [{ type: "text", text: "hello" }],
}, },
}) })
+1 -1
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", bytes: 16, truncated: false },
content: [{ type: "text", text: "parallel results" }], content: [{ type: "text", text: "parallel results" }],
}, },
}) })
+4
View File
@@ -97,6 +97,10 @@ export type Definition<
readonly input: Input readonly input: Input
readonly output: Output readonly output: Output
readonly structured?: Structured readonly structured?: Structured
/** Expose encoded output to CodeMode instead of the persisted structured projection. */
readonly codeModeOutput?: "output"
/** Mark a receipt's `truncated` field when its model-facing text is bounded. */
readonly contentTruncation?: true
readonly permission?: string readonly permission?: string
readonly toStructuredOutput?: (input: { readonly toStructuredOutput?: (input: {
readonly input: InputValue<Input> readonly input: InputValue<Input>