feat(core): bound v2 tool output (#30999)

This commit is contained in:
Kit Langton 2026-06-05 14:35:19 -04:00 committed by GitHub
commit a9094fd059
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 387 additions and 552 deletions

View file

@ -13,9 +13,10 @@ import { ProjectReference } from "./project-reference"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { Protected } from "./filesystem/protected"
import { Ripgrep } from "./filesystem/ripgrep"
import { ToolOutputStore } from "./tool-output-store"
export const ReadInput = Schema.Struct({
path: RelativePath,
path: Schema.String,
reference: Schema.NonEmptyString.pipe(Schema.optional),
})
export type ReadInput = typeof ReadInput.Type
@ -65,7 +66,7 @@ export class ReadTarget extends Schema.Class<ReadTarget>("FileSystem.ReadTarget"
}) {}
export const ListInput = Schema.Struct({
path: RelativePath.pipe(Schema.optional),
path: Schema.String.pipe(Schema.optional),
reference: Schema.NonEmptyString.pipe(Schema.optional),
})
export type ListInput = typeof ListInput.Type
@ -181,6 +182,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const global = yield* Effect.serviceOption(Global.Service)
const references = yield* ProjectReference.Service
const ripgrep = yield* Ripgrep.Service
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
@ -201,8 +203,21 @@ export const layer = Layer.effect(
if (resolved.kind === "git") yield* references.ensurePath(resolved.path).pipe(Effect.orDie)
return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) }
})
const resolve = Effect.fnUntraced(function* (input?: RelativePath, reference?: string) {
if (input && path.isAbsolute(input)) return yield* Effect.die(new Error("Path must be relative to the location"))
const resolve = Effect.fnUntraced(function* (input?: string, reference?: string) {
const managed = path.join(
Option.match(global, { onNone: () => Global.Path.data, onSome: (value) => value.data }),
ToolOutputStore.MANAGED_DIRECTORY,
)
if (input && path.isAbsolute(input)) {
if (reference) return yield* Effect.die(new Error("Absolute paths cannot use a project reference"))
if (path.dirname(input) !== managed || !path.basename(input).startsWith("tool_"))
return yield* Effect.die(new Error("Absolute path is not managed tool output"))
const real = yield* fs.realPath(input).pipe(Effect.orDie)
const managedRoot = yield* fs.realPath(managed).pipe(Effect.orDie)
if (path.dirname(real) !== managedRoot || !path.basename(real).startsWith("tool_"))
return yield* Effect.die(new Error("Path escapes managed tool output"))
return { absolute: input, real, directory: managed, root: managedRoot }
}
const selected = yield* select(reference)
const absolute = path.resolve(selected.directory, input ?? ".")
if (!FSUtil.contains(selected.directory, absolute))

View file

@ -46,9 +46,8 @@ import { FetchHttpClient } from "effect/unstable/http"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
const location = Location.layer(ref)
const permissionsAndTools = ToolRegistry.layer.pipe(Layer.provideMerge(PermissionV2.locationLayer))
const systemContext = SystemContextBuiltIns.locationLayer
const services = Layer.mergeAll(
const base = Layer.mergeAll(
location,
Policy.locationLayer,
Config.locationLayer,
@ -63,13 +62,18 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Pty.locationLayer,
SkillV2.locationLayer,
systemContext,
permissionsAndTools,
LocationMutation.locationLayer.pipe(Layer.orDie),
).pipe(Layer.provideMerge(location))
const resources = ToolOutputStore.layer.pipe(Layer.provide(base))
const permissionsAndTools = ToolRegistry.layer.pipe(
Layer.provideMerge(PermissionV2.locationLayer),
Layer.provide(resources),
Layer.provide(base),
)
const services = Layer.mergeAll(base, resources, permissionsAndTools)
const commits = FileMutation.locationLayer.pipe(Layer.provide(services))
const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services))
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
const resources = ToolOutputStore.layer.pipe(Layer.provide(services))
const todos = SessionTodo.layer.pipe(Layer.provide(services))
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
const builtInTools = BuiltInTools.locationLayer.pipe(

View file

@ -25,7 +25,7 @@ export const MAX_LINE_PREVIEW_LENGTH = 2_000
export const ResultLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_RESULT_LIMIT))
const RootInput = {
path: RelativePath.pipe(Schema.optional),
path: Schema.String.pipe(Schema.optional),
reference: Schema.NonEmptyString.pipe(Schema.optional),
}

View file

@ -373,6 +373,7 @@ export namespace Tool {
...ToolBase,
structured: ToolOutput.Structured,
content: Schema.Array(ToolOutput.Content),
outputPaths: Schema.Array(Schema.String).pipe(Schema.optional),
result: Schema.Unknown.pipe(Schema.optional),
provider: Schema.Struct({
executed: Schema.Boolean,

View file

@ -308,6 +308,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
input: match.state.input,
structured: event.data.structured,
content: [...event.data.content],
outputPaths: event.data.outputPaths ? [...event.data.outputPaths] : [],
result: event.data.result,
}),
)

View file

@ -86,6 +86,7 @@ export class ToolStateCompleted extends Schema.Class<ToolStateCompleted>("Sessio
input: Schema.Record(Schema.String, Schema.Unknown),
attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional),
content: ToolOutput.Content.pipe(Schema.Array),
outputPaths: SessionEvent.Tool.Success.data.fields.outputPaths,
structured: ToolOutput.Structured,
result: SessionEvent.Tool.Success.data.fields.result,
}) {}

View file

@ -207,7 +207,8 @@ export const layer = Layer.effect(
},
})
const withPublication = Semaphore.makeUnsafe(1).withPermit
const publish = (event: LLMEvent) => withPublication(publisher.publish(event))
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
withPublication(publisher.publish(event, outputPaths))
if (!(yield* SessionContextEpoch.current(db, session.id, agent.id, system.revision)))
return yield* Effect.die(new RetryTurn(undefined))
const providerStream = llm.stream(request).pipe(
@ -216,26 +217,29 @@ export const layer = Layer.effect(
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
needsContinuation = true
yield* tools.settle({ sessionID: session.id, agent: agent.id, call: event }).pipe(
Effect.catchCause((cause) => {
if (isQuestionRejected(cause)) return Effect.failCause(cause)
return Effect.succeed({
result: { type: "error" as const, value: String(Cause.squash(cause)) },
output: undefined,
})
}),
Effect.flatMap((settlement) =>
publish(
LLMEvent.toolResult({
id: event.id,
name: event.name,
result: settlement.result,
output: settlement.output,
}),
yield* Effect.uninterruptibleMask((restore) =>
restore(tools.settle({ sessionID: session.id, agent: agent.id, call: event })).pipe(
Effect.catchCause((cause) => {
if (isQuestionRejected(cause) || Cause.hasInterrupts(cause)) return Effect.failCause(cause)
return Effect.succeed({
result: { type: "error" as const, value: String(Cause.squash(cause)) },
output: undefined,
outputPaths: [],
})
}),
Effect.flatMap((settlement) =>
publish(
LLMEvent.toolResult({
id: event.id,
name: event.name,
result: settlement.result,
output: settlement.output,
}),
settlement.outputPaths ?? [],
),
),
),
FiberSet.run(toolFibers),
)
).pipe(FiberSet.run(toolFibers))
}),
),
Effect.ensuring(withPublication(publisher.flush())),

View file

@ -218,7 +218,10 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
}
})
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
event: LLMEvent,
outputPaths: ReadonlyArray<string> = [],
) {
switch (event.type) {
case "step-start":
yield* startAssistant()
@ -347,6 +350,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
assistantMessageID: tool.assistantMessageID,
callID: event.id,
...result,
outputPaths,
result: event.result,
provider,
})

View file

@ -1,57 +1,19 @@
export * as ToolOutputStore from "./tool-output-store"
import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { Context, Duration, Effect, Layer, Option, Schedule } from "effect"
import { Config } from "./config"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { NonNegativeInt, PositiveInt } from "./schema"
import { SessionSchema } from "./session/schema"
import { Identifier } from "./util/identifier"
import type { ToolOutput } from "@opencode-ai/llm"
export const MAX_LINES = 2_000
export const MAX_BYTES = 50 * 1024
export const MAX_READ_BYTES = 50 * 1024
export const RETENTION = Duration.days(7)
const URI_PREFIX = "tool-output://"
const MANAGED_DIRECTORY = path.join("tool-output", "managed")
const ID_PATTERN = /^[0-9a-f]{12}[0-9A-Za-z]{14}$/
export class Resource extends Schema.Class<Resource>("ToolOutputStore.Resource")({
uri: Schema.String,
mime: Schema.String,
name: Schema.String.pipe(Schema.optional),
size: NonNegativeInt,
}) {}
export class Page extends Schema.Class<Page>("ToolOutputStore.Page")({
resource: Resource,
content: Schema.String,
offset: NonNegativeInt,
truncated: Schema.Boolean,
next: NonNegativeInt.pipe(Schema.optional),
}) {}
export class AccessDeniedError extends Schema.TaggedErrorClass<AccessDeniedError>()(
"ToolOutputStore.AccessDeniedError",
{
uri: Schema.String,
sessionID: SessionSchema.ID,
},
) {}
export class InvalidResourceError extends Schema.TaggedErrorClass<InvalidResourceError>()(
"ToolOutputStore.InvalidResourceError",
{
uri: Schema.String,
},
) {}
export class ResourceNotFoundError extends Schema.TaggedErrorClass<ResourceNotFoundError>()(
"ToolOutputStore.ResourceNotFoundError",
{ uri: Schema.String },
) {}
export const MANAGED_DIRECTORY = "tool-output"
export interface WriteInput {
readonly sessionID: SessionSchema.ID
@ -66,70 +28,31 @@ export interface TruncateInput extends WriteInput {
readonly maxBytes?: number
}
export interface ReadInput {
readonly sessionID: SessionSchema.ID
readonly uri: string
/** Zero-based byte offset. Returned `next` values preserve UTF-8 boundaries. */
readonly offset?: number
readonly limit?: number
}
export type TruncateResult =
| { readonly content: string; readonly truncated: false }
| { readonly content: string; readonly truncated: true; readonly resource: Resource }
| { readonly content: string; readonly truncated: true; readonly outputPath: string }
interface Record {
readonly version: 1
readonly id: string
readonly uri: string
readonly sessionID: string
export interface BoundInput {
readonly sessionID: SessionSchema.ID
readonly toolCallID: string
readonly mime: string
readonly name?: string
readonly size: number
readonly created: number
readonly output: ToolOutput
}
export interface BoundResult {
readonly output: ToolOutput
readonly outputPaths: ReadonlyArray<string>
}
export interface Interface {
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
readonly write: (input: WriteInput) => Effect.Effect<Resource>
readonly write: (input: WriteInput) => Effect.Effect<string>
readonly truncate: (input: TruncateInput) => Effect.Effect<TruncateResult>
readonly read: (
input: ReadInput,
) => Effect.Effect<Page, AccessDeniedError | InvalidResourceError | ResourceNotFoundError>
readonly bound: (input: BoundInput) => Effect.Effect<BoundResult>
readonly cleanup: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolOutputStore") {}
const uri = (id: string) => URI_PREFIX + id
const idFromUri = (input: string) => {
if (!input.startsWith(URI_PREFIX)) return
const id = input.slice(URI_PREFIX.length)
if (!ID_PATTERN.test(id)) return
return id
}
const validRecord = (input: unknown, id: string): input is Record => {
if (!input || typeof input !== "object") return false
const record = input as Partial<Record>
return (
record.version === 1 &&
record.id === id &&
record.uri === uri(id) &&
typeof record.sessionID === "string" &&
typeof record.toolCallID === "string" &&
typeof record.mime === "string" &&
(record.name === undefined || typeof record.name === "string") &&
typeof record.size === "number" &&
Number.isSafeInteger(record.size) &&
record.size >= 0 &&
typeof record.created === "number" &&
Number.isFinite(record.created)
)
}
const takePrefix = (input: string, maximumBytes: number) => {
let bytes = 0
let content = ""
@ -178,6 +101,14 @@ const preview = (text: string, maxLines: number, maxBytes: number) => {
return { head: takePrefix(sampled, headBytes), tail: takeSuffix(sampled, tailBytes) }
}
const boundedPreview = (text: string, marker: string, maxLines: number, maxBytes: number) => {
const markerOnly = takePrefix(marker, maxBytes).split("\n").slice(0, maxLines).join("\n")
const markerBytes = Buffer.byteLength(marker, "utf-8")
if (maxLines <= 4 || maxBytes <= markerBytes + 4) return markerOnly
const bounded = preview(text, maxLines - 4, maxBytes - markerBytes - 4)
return bounded.tail ? `${bounded.head}\n\n${marker}\n\n${bounded.tail}` : `${bounded.head}\n\n${marker}`
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
@ -185,21 +116,6 @@ export const layer = Layer.effect(
const global = yield* Global.Service
const config = yield* Effect.serviceOption(Config.Service)
const directory = path.join(global.data, MANAGED_DIRECTORY)
const metadataPath = (id: string) => path.join(directory, `${id}.json`)
const contentPath = (id: string) => path.join(directory, `${id}.txt`)
const load = Effect.fn("ToolOutputStore.load")(function* (resourceUri: string) {
const id = idFromUri(resourceUri)
if (!id) return yield* Effect.fail(new InvalidResourceError({ uri: resourceUri }))
const text = yield* fs.readFileStringSafe(metadataPath(id)).pipe(Effect.orDie)
if (!text) return yield* Effect.fail(new ResourceNotFoundError({ uri: resourceUri }))
const record = yield* Effect.sync(() => JSON.parse(text)).pipe(Effect.catch(() => Effect.void))
if (!validRecord(record, id)) return yield* Effect.fail(new ResourceNotFoundError({ uri: resourceUri }))
const info = yield* fs.stat(contentPath(id)).pipe(Effect.catch(() => Effect.void))
if (!info || info.type !== "File" || Number(info.size) !== record.size)
return yield* Effect.fail(new ResourceNotFoundError({ uri: resourceUri }))
return record
})
const limits = Effect.fn("ToolOutputStore.limits")(function* () {
if (Option.isNone(config)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES }
@ -212,32 +128,10 @@ export const layer = Layer.effect(
})
const write = Effect.fn("ToolOutputStore.write")(function* (input: WriteInput) {
const id = Identifier.ascending()
const resourceUri = uri(id)
const size = Buffer.byteLength(input.content, "utf-8")
const record: Record = {
version: 1,
id,
uri: resourceUri,
sessionID: input.sessionID,
toolCallID: input.toolCallID,
mime: input.mime ?? "text/plain",
...(input.name === undefined ? {} : { name: input.name }),
size,
created: Date.now(),
}
const file = path.join(directory, `tool_${Identifier.ascending()}`)
yield* fs.ensureDir(directory).pipe(Effect.orDie)
yield* fs.writeFileString(contentPath(id), input.content, { flag: "wx" }).pipe(Effect.orDie)
yield* fs.writeFileString(metadataPath(id), JSON.stringify(record), { flag: "wx" }).pipe(
Effect.onError(() => fs.remove(contentPath(id)).pipe(Effect.catch(() => Effect.void))),
Effect.orDie,
)
return new Resource({
uri: resourceUri,
mime: record.mime,
...(record.name === undefined ? {} : { name: record.name }),
size,
})
yield* fs.writeFileString(file, input.content, { flag: "wx" }).pipe(Effect.orDie)
return file
})
const truncate = Effect.fn("ToolOutputStore.truncate")(function* (input: TruncateInput) {
@ -247,105 +141,73 @@ export const layer = Layer.effect(
if (input.content.split("\n").length <= maxLines && Buffer.byteLength(input.content, "utf-8") <= maxBytes) {
return { content: input.content, truncated: false } as const
}
const resource = yield* write(input)
const bounded = preview(input.content, maxLines, maxBytes)
const marker = `... output truncated; full content available as ${resource.uri} ...`
const outputPath = yield* write(input)
const marker = `... output truncated; full content saved to ${outputPath} ...`
return {
content: bounded.tail ? `${bounded.head}\n\n${marker}\n\n${bounded.tail}` : `${bounded.head}\n\n${marker}`,
content: boundedPreview(input.content, marker, maxLines, maxBytes),
truncated: true,
resource,
outputPath,
} as const
})
const read = Effect.fn("ToolOutputStore.read")(function* (input: ReadInput) {
const record = yield* load(input.uri)
if (record.sessionID !== input.sessionID) {
return yield* Effect.fail(new AccessDeniedError({ uri: input.uri, sessionID: input.sessionID }))
}
const offset = Math.max(0, Math.min(input.offset ?? 0, record.size))
const limit = Math.max(1, Math.min(input.limit ?? MAX_READ_BYTES, MAX_READ_BYTES))
const bytes = yield* Effect.scoped(
Effect.gen(function* () {
const file = yield* fs.open(contentPath(record.id), { flag: "r" }).pipe(Effect.orDie)
yield* file.seek(offset, "start")
const chunk = yield* file.readAlloc(Math.min(limit + 3, record.size - offset)).pipe(Effect.orDie)
return Option.getOrElse(chunk, () => new Uint8Array())
}),
const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
const text = input.output.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n\n")
const structured = yield* Effect.sync(() => JSON.stringify(input.output.structured)).pipe(
Effect.catch(() => Effect.succeed(String(input.output.structured))),
)
let start = 0
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++
let end = Math.min(start + limit, bytes.length)
while (end > start && end < bytes.length && (bytes[end] & 0xc0) === 0x80) end--
if (end === start && end < bytes.length) {
end = Math.min(start + limit, bytes.length)
while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end++
const content = text || input.output.content.length > 0 ? text : structured
if (content === undefined) return { output: input.output, outputPaths: [] }
const truncated = yield* truncate({
sessionID: input.sessionID,
toolCallID: input.toolCallID,
content,
mime: "text/plain",
name: `${input.toolCallID}.txt`,
}).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("Unable to retain complete tool output", cause).pipe(
Effect.andThen(limits()),
Effect.map(({ maxLines, maxBytes }) => {
const marker = "... output truncated; omitted content could not be retained ..."
return {
content: boundedPreview(content, marker, maxLines, maxBytes),
truncated: true as const,
}
}),
),
),
)
if (!truncated.truncated) return { output: input.output, outputPaths: [] }
return {
output: {
structured: input.output.structured,
content: [
{ type: "text" as const, text: truncated.content },
...input.output.content.filter((item) => item.type === "file"),
],
},
outputPaths: "outputPath" in truncated ? [truncated.outputPath] : [],
}
const absoluteStart = offset + start
const absoluteEnd = offset + end
const truncated = absoluteEnd < record.size
return new Page({
resource: new Resource({
uri: record.uri,
mime: record.mime,
...(record.name === undefined ? {} : { name: record.name }),
size: record.size,
}),
content: Buffer.from(bytes.subarray(start, end)).toString("utf-8"),
offset: absoluteStart,
truncated,
...(truncated ? { next: absoluteEnd } : {}),
})
})
const cleanup = Effect.fn("ToolOutputStore.cleanup")(function* () {
const entries = yield* fs.readDirectory(directory).pipe(Effect.catch(() => Effect.succeed([])))
const cutoff = Date.now() - Duration.toMillis(RETENTION)
const ids = new Set(
entries.flatMap((entry) => {
const match = entry.match(/^([0-9a-f]{12}[0-9A-Za-z]{14})\.(?:json|txt)$/)
return match ? [match[1]] : []
}),
)
const removeIfPresent = (target: string) =>
fs.existsSafe(target).pipe(Effect.flatMap((exists) => (exists ? fs.remove(target) : Effect.void)))
const removePair = (id: string) =>
Effect.gen(function* () {
yield* removeIfPresent(contentPath(id))
yield* removeIfPresent(metadataPath(id))
}).pipe(Effect.catch(() => Effect.void))
for (const id of ids) {
const text = yield* fs.readFileStringSafe(metadataPath(id)).pipe(Effect.catch(() => Effect.succeed(undefined)))
const contentExists = yield* fs.existsSafe(contentPath(id))
if (!text) {
if (!contentExists) continue
const info = yield* fs.stat(contentPath(id)).pipe(Effect.catch(() => Effect.void))
const modified = info
? info.mtime.pipe(
Option.map((date) => date.getTime()),
Option.getOrElse(() => 0),
)
: 0
if (modified < cutoff) yield* removePair(id)
continue
}
const record = yield* Effect.try({
try: () => JSON.parse(text),
catch: () => new globalThis.Error("Invalid metadata"),
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
const info = contentExists ? yield* fs.stat(contentPath(id)).pipe(Effect.catch(() => Effect.void)) : undefined
if (
!contentExists ||
!validRecord(record, id) ||
!info ||
info.type !== "File" ||
Number(info.size) !== record.size ||
record.created < cutoff
for (const entry of entries) {
if (!entry.startsWith("tool_")) continue
const file = path.join(directory, entry)
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.void))
const modified = info?.mtime.pipe(
Option.map((date) => date.getTime()),
Option.getOrElse(() => 0),
)
yield* removePair(id)
if (modified !== undefined && modified < cutoff) yield* fs.remove(file).pipe(Effect.catch(() => Effect.void))
}
})
return Service.of({ limits, write, truncate, read, cleanup })
return Service.of({ limits, write, truncate, bound, cleanup })
}),
)

View file

@ -41,7 +41,7 @@ const Success = Schema.Struct({
truncated: Schema.Boolean,
stdoutTruncated: Schema.Boolean.pipe(Schema.optional),
stderrTruncated: Schema.Boolean.pipe(Schema.optional),
resource: ToolOutputStore.Resource.pipe(Schema.optional),
outputPath: Schema.String.pipe(Schema.optional),
timedOut: Schema.Boolean.pipe(Schema.optional),
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
})
@ -121,6 +121,7 @@ export const layer = Layer.effectDiscard(
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
execute: ({ parameters, sessionID, call, assertPermission }) =>
Effect.gen(function* () {
const plan = yield* mutation.resolve({ path: parameters.workdir ?? ".", kind: "directory" })
@ -187,7 +188,7 @@ export const layer = Layer.effectDiscard(
...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
...(result.stderrTruncated ? { stderrTruncated: true } : {}),
...(truncated.truncated && !result.stdoutTruncated && !result.stderrTruncated
? { resource: truncated.resource }
? { outputPath: truncated.outputPath }
: {}),
}
}).pipe(

View file

@ -53,7 +53,7 @@ export const toModelOutput = (output: Success) => {
const definition = Tool.make({
description:
"Search file contents by regular expression within the active Location or a named project reference. Use a relative path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise relative file resources, line numbers, and bounded line previews.",
"Search file contents by regular expression within the active Location, a named project reference, 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.",
parameters: Parameters,
success: LocationSearch.GrepResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],

View file

@ -3,9 +3,7 @@ export * as ReadTool from "./read"
import { Tool, ToolFailure } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { NonNegativeInt, PositiveInt } from "../schema"
import { PermissionV2 } from "../permission"
import { ToolOutputStore } from "../tool-output-store"
import { ToolRegistry } from "./registry"
export const name = "read"
@ -18,17 +16,12 @@ const LocationInput = Schema.Struct({
description: "The maximum number of directory entries or text lines to read",
}),
})
const ResourceInput = Schema.Struct({
resource: Schema.String,
offset: NonNegativeInt.pipe(Schema.optional),
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(ToolOutputStore.MAX_READ_BYTES)).pipe(Schema.optional),
})
const Input = Schema.Union([LocationInput, ResourceInput])
const Success = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage, ToolOutputStore.Page])
const Input = LocationInput
const Success = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage])
const definition = Tool.make({
description:
"Read a text or binary file, page through a large UTF-8 text file by line offset, list a directory page relative to the current location, or page through a managed tool-output resource by opaque URI.",
"Read a text or binary file, page through a large UTF-8 text file by line offset, or list a directory page relative to the current location. Absolute paths are accepted only for managed tool-output files.",
parameters: Input,
success: Success,
})
@ -37,7 +30,6 @@ export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const filesystem = yield* FileSystem.Service
const resources = yield* ToolOutputStore.Service
yield* registry.contribute((editor) =>
editor.set(name, {
@ -45,8 +37,6 @@ export const layer = Layer.effectDiscard(
execute: ({ parameters, sessionID, assertPermission }) => {
const input = parameters
return Effect.gen(function* () {
if ("resource" in input)
return yield* resources.read({ sessionID, uri: input.resource, offset: input.offset, limit: input.limit })
const resolved = yield* filesystem.resolveReadPath(input)
if (resolved.type === "directory") {
const { offset, limit } = input
@ -81,7 +71,7 @@ export const layer = Layer.effectDiscard(
Effect.catchCause((cause) =>
Effect.fail(
new ToolFailure({
message: `Unable to read ${"resource" in input ? input.resource : input.path}`,
message: `Unable to read ${input.path}`,
error: Cause.squash(cause),
}),
),
@ -96,5 +86,4 @@ export const locationLayer = layer.pipe(
Layer.provideMerge(ToolRegistry.defaultLayer),
Layer.provideMerge(FileSystem.locationLayer),
Layer.provideMerge(PermissionV2.locationLayer),
Layer.provideMerge(ToolOutputStore.defaultLayer),
)

View file

@ -18,6 +18,7 @@ import { State } from "../state"
import { SessionSchema } from "../session/schema"
import type { SessionV2 } from "../session"
import { ApplicationTools } from "./application-tools"
import { ToolOutputStore } from "../tool-output-store"
import { AgentV2 } from "../agent"
export type ExecuteInput = {
@ -57,6 +58,7 @@ export type Entry<
readonly execute?: (
input: AuthorizeInput<Schema.Schema.Type<Parameters>>,
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
readonly outputPaths?: (output: Schema.Schema.Type<Success>) => ReadonlyArray<string>
}
type Data = {
@ -78,7 +80,11 @@ export interface Interface {
readonly contribute: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
readonly definitions: () => Effect.Effect<ReadonlyArray<ReturnType<typeof Tool.toDefinitions>[number]>>
readonly execute: (input: ExecuteInput) => Effect.Effect<ToolResultValue>
readonly settle: (input: ExecuteInput) => Effect.Effect<ToolSettlement>
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement>
}
export interface Settlement extends ToolSettlement {
readonly outputPaths?: ReadonlyArray<string>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
@ -90,6 +96,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const permission = yield* PermissionV2.Service
const applications = yield* ApplicationTools.Service
const resources = yield* ToolOutputStore.Service
const state = State.create<Data, Editor>({
initial: () => ({ entries: new Map() }),
editor: (draft) => ({
@ -162,12 +169,16 @@ export const layer = Layer.effect(
),
),
),
Effect.map((value): ToolSettlement => {
if (entry.tool._legacyResult && ToolResult.is(value))
return { result: value, output: ToolOutput.fromResultValue(value) }
const output = entry.tool._project(parameters, input.call.id, value)
const result = ToolOutput.toResultValue(output)
return result.type === "error" ? { result } : { result, output }
Effect.map((value): Settlement => {
const settled = (() => {
if (entry.tool._legacyResult && ToolResult.is(value))
return { result: value, output: ToolOutput.fromResultValue(value) }
const output = entry.tool._project(parameters, input.call.id, value)
const result = ToolOutput.toResultValue(output)
return result.type === "error" ? { result } : { result, output }
})()
const retained = entry.outputPaths?.(value) ?? []
return retained.length > 0 ? { ...settled, outputPaths: retained } : settled
}),
)
}),
@ -177,7 +188,25 @@ export const layer = Layer.effect(
)
})
const settle = Effect.fn("ToolRegistry.settle")((input: ExecuteInput) => settleEntry(entry(input.call.name), input))
const settle = Effect.fn("ToolRegistry.settle")((input: ExecuteInput) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const settled = yield* restore(settleEntry(entry(input.call.name), input))
if (!settled.output) return settled
const bounded = yield* resources.bound({
sessionID: input.sessionID,
toolCallID: input.call.id,
output: settled.output,
})
if (bounded.output === settled.output && bounded.outputPaths.length === 0) return settled
const retained = [...(settled.outputPaths ?? []), ...bounded.outputPaths]
const result = ToolOutput.toResultValue(bounded.output)
return result.type === "error"
? { result, outputPaths: retained }
: { result, output: bounded.output, outputPaths: retained }
}),
),
)
const execute = Effect.fn("ToolRegistry.execute")(function* (input: ExecuteInput) {
return (yield* settle(input)).result
})
@ -195,4 +224,7 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(ApplicationTools.layer))
export const defaultLayer = layer.pipe(
Layer.provide(ApplicationTools.layer),
Layer.provide(ToolOutputStore.defaultLayer),
)

View file

@ -22,7 +22,7 @@ export const Success = Schema.Struct({
directory: Schema.String,
output: Schema.String,
truncated: Schema.Boolean,
resource: ToolOutputStore.Resource.pipe(Schema.optional),
outputPath: Schema.String.pipe(Schema.optional),
})
export const description = [
@ -73,6 +73,7 @@ export const layer = Layer.effectDiscard(
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
execute: ({ parameters, sessionID, call, assertPermission }) =>
Effect.gen(function* () {
const current = yield* skills.list()
@ -98,7 +99,7 @@ export const layer = Layer.effectDiscard(
directory,
output: output.content,
truncated: output.truncated,
...(output.truncated ? { resource: output.resource } : {}),
...(output.truncated ? { outputPath: output.outputPath } : {}),
}
}).pipe(Effect.catchCause((cause) => Effect.fail(unableToLoad(parameters.name, Cause.squash(cause)))))
}),

View file

@ -15,7 +15,7 @@ export const MAX_TIMEOUT_SECONDS = 120
export const description = `Fetch content from an HTTP or HTTPS URL and return it as text, markdown, or HTML. Markdown is the default.
Use a more targeted tool when one is available. This tool is read-only. Large text results are truncated with an opaque managed resource URI for paging.`
Use a more targeted tool when one is available. This tool is read-only. Large text results are truncated and saved to a managed file that ordinary Read, Grep, and Bash tools can inspect.`
const Timeout = Schema.Number.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_TIMEOUT_SECONDS))
@ -35,7 +35,7 @@ const Success = Schema.Struct({
format: Parameters.fields.format,
output: Schema.String,
truncated: Schema.Boolean,
resource: ToolOutputStore.Resource.pipe(Schema.optional),
outputPath: Schema.String.pipe(Schema.optional),
})
type Format = (typeof Parameters.Type)["format"]
@ -141,6 +141,7 @@ export const layer = Layer.effectDiscard(
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
execute: ({ parameters, sessionID, call, assertPermission }) =>
Effect.gen(function* () {
const parsed = new URL(parameters.url)
@ -178,7 +179,7 @@ export const layer = Layer.effectDiscard(
format: parameters.format,
output: truncated.content,
truncated: truncated.truncated,
...(truncated.truncated ? { resource: truncated.resource } : {}),
...(truncated.truncated ? { outputPath: truncated.outputPath } : {}),
}
}).pipe(
Effect.catchCause((cause) =>

View file

@ -179,7 +179,7 @@ const Success = Schema.Struct({
provider: Provider,
text: Schema.String,
truncated: Schema.Boolean,
resource: ToolOutputStore.Resource.pipe(Schema.optional),
outputPath: Schema.String.pipe(Schema.optional),
})
const definition = Tool.make({
@ -199,6 +199,7 @@ export const layer = Layer.effectDiscard(
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
execute: ({ parameters, sessionID, call, assertPermission }) => {
const provider = selectProvider(sessionID, config, config.provider)
return Effect.gen(function* () {
@ -239,7 +240,7 @@ export const layer = Layer.effectDiscard(
provider,
text: truncated.content,
truncated: truncated.truncated,
...(truncated.truncated ? { resource: truncated.resource } : {}),
...(truncated.truncated ? { outputPath: truncated.outputPath } : {}),
}
}).pipe(
Effect.catchCause((cause) =>

View file

@ -4,6 +4,7 @@ import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Effect, Exit, Layer, Schema, Scope } from "effect"
import { testEffect } from "./lib/effect"
@ -11,7 +12,11 @@ const permission = Layer.mock(PermissionV2.Service, {
assert: () => Effect.void,
})
const applications = ApplicationTools.layer
const registry = ToolRegistry.layer.pipe(Layer.provide(permission), Layer.provide(applications))
const registry = ToolRegistry.layer.pipe(
Layer.provide(permission),
Layer.provide(applications),
Layer.provide(ToolOutputStore.defaultLayer),
)
const it = testEffect(Layer.mergeAll(applications, registry))
const sessionID = SessionV2.ID.make("ses_application_tool")

View file

@ -9,6 +9,7 @@ import { FileSystem } from "@opencode-ai/core/filesystem"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { ProjectReference } from "@opencode-ai/core/project-reference"
import { Repository } from "@opencode-ai/core/repository"
import { Global } from "@opencode-ai/core/global"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
@ -22,7 +23,12 @@ const inertReferences = ProjectReference.Service.of({
containsManagedPath: () => Effect.succeed(false),
})
function provide(directory: string, references = inertReferences, filesystem = FSUtil.defaultLayer) {
function provide(
directory: string,
references = inertReferences,
filesystem = FSUtil.defaultLayer,
data = Global.Path.data,
) {
return Effect.provide(
FileSystem.layer.pipe(
Layer.provide(
@ -31,6 +37,7 @@ function provide(directory: string, references = inertReferences, filesystem = F
Ripgrep.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Layer.succeed(ProjectReference.Service, references),
Global.layerWith({ data }),
),
),
),
@ -45,6 +52,27 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
}
describe("FileSystem", () => {
it.live("accepts generated managed output paths and rejects other absolute paths", () =>
withTmp((directory) => {
const worktree = directory
const data = path.join(directory, "data")
return Effect.gen(function* () {
const managed = path.join(data, "tool-output")
const output = path.join(managed, "tool_123")
const unrelated = path.join(directory, "secret.txt")
yield* Effect.promise(() => fs.mkdir(managed, { recursive: true }))
yield* Effect.promise(() => fs.writeFile(output, "failure here"))
yield* Effect.promise(() => fs.writeFile(unrelated, "secret"))
const service = yield* FileSystem.Service
expect(yield* service.read({ path: output })).toMatchObject({ type: "text", content: "failure here" })
expect((yield* service.resolveRoot({ path: output })).real).toBe(output)
expect(yield* Effect.exit(service.read({ path: unrelated }))).toMatchObject({ _tag: "Failure" })
expect(yield* Effect.exit(service.read({ path: managed }))).toMatchObject({ _tag: "Failure" })
}).pipe(provide(worktree, inertReferences, FSUtil.defaultLayer, data))
}),
)
it.live("reads text and binary files", () =>
withTmp((directory) =>
Effect.gen(function* () {

View file

@ -11,19 +11,21 @@ import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgr
import { ProjectReference } from "@opencode-ai/core/project-reference"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/core/global"
import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
import { it } from "./lib/effect"
const inertReferences = references({})
function provide(directory: string, projectReferences = inertReferences) {
function provide(directory: string, projectReferences = inertReferences, data = Global.Path.data) {
const dependencies = Layer.mergeAll(
FSUtil.defaultLayer,
FileSystemRipgrep.defaultLayer,
AppProcess.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Layer.succeed(ProjectReference.Service, projectReferences),
Global.layerWith({ data }),
)
const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
const search = LocationSearch.layer.pipe(
@ -43,6 +45,21 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
}
describe("LocationSearch", () => {
it.live("greps an absolute managed tool-output file", () =>
withTmp((directory) => {
const data = path.join(directory, "data")
const managed = path.join(data, "tool-output")
const output = path.join(managed, "tool_123")
return Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(managed, { recursive: true }))
yield* Effect.promise(() => fs.writeFile(output, "ok\nFAIL here\nok"))
const search = yield* LocationSearch.Service
const result = yield* search.grep({ pattern: "FAIL", path: output })
expect(result.items).toMatchObject([{ canonical: output, line: 2, lines: "FAIL here\n" }])
}).pipe(provide(directory, inertReferences, data))
}),
)
it.live("searches files in the active Location with structured bounded results", () =>
withTmp((directory) =>
Effect.gen(function* () {

View file

@ -3,6 +3,8 @@ import { Tool, ToolFailure } from "@opencode-ai/llm"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { Effect, Exit, Layer, Schema, Scope } from "effect"
import { testEffect } from "./lib/effect"
@ -24,7 +26,15 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const bounds: ToolOutputStore.BoundInput[] = []
const outputStore = Layer.mock(ToolOutputStore.Service, {
bound: (input) => Effect.sync(() => bounds.push(input)).pipe(Effect.as({ output: input.output, outputPaths: [] })),
})
const registry = ToolRegistry.layer.pipe(
Layer.provide(permission),
Layer.provide(ApplicationTools.layer),
Layer.provide(outputStore),
)
const it = testEffect(Layer.mergeAll(permission, registry))
const echo = Tool.make({
@ -180,6 +190,7 @@ describe("ToolRegistry", () => {
it.effect("settles encoded structured output with canonical projected content", () =>
Effect.gen(function* () {
bounds.length = 0
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
@ -202,10 +213,17 @@ describe("ToolRegistry", () => {
sessionID: SessionV2.ID.make("ses_registry_test"),
call: { type: "tool-call", id: "call-projected", name: "projected", input: { prefix: "count" } },
}),
).toEqual({
).toMatchObject({
result: { type: "text", value: "call-projected:count:2" },
output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] },
})
expect(bounds).toEqual([
{
sessionID: SessionV2.ID.make("ses_registry_test"),
toolCallID: "call-projected",
output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] },
},
])
}),
)
})

View file

@ -32,6 +32,7 @@ import { SessionRunner } from "@opencode-ai/core/session/runner"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
@ -117,7 +118,11 @@ const permission = Layer.succeed(
}),
)
const applications = ApplicationTools.layer
const registry = ToolRegistry.layer.pipe(Layer.provide(permission), Layer.provide(applications))
const registry = ToolRegistry.layer.pipe(
Layer.provide(permission),
Layer.provide(applications),
Layer.provide(ToolOutputStore.defaultLayer),
)
const agents = AgentV2.layer
const echo = Layer.effectDiscard(
ToolRegistry.Service.use((registry) =>

View file

@ -74,7 +74,7 @@ const resources = Layer.succeed(
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
read: () => Effect.die("unused"),
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
cleanup: () => Effect.die("unused"),
}),
)
@ -295,7 +295,7 @@ describe("BashTool", () => {
),
)
it.live("keeps non-zero exits useful and exposes managed overflow by opaque URI", () =>
it.live("keeps non-zero exits useful and exposes managed overflow by path", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
@ -303,13 +303,9 @@ describe("BashTool", () => {
result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") }
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content available as tool-output://opaque ...\n\nTAIL",
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
truncated: true,
resource: new ToolOutputStore.Resource({
uri: "tool-output://opaque",
mime: "text/plain",
size: input.content.length,
}),
outputPath: "/tmp/tool-output/tool_opaque",
})
return withTool(tmp.path, (registry) => registry.settle(call({ command: "false" }, "call-overflow"))).pipe(
Effect.andThen((settled) =>
@ -323,12 +319,12 @@ describe("BashTool", () => {
cwd: realpathSync(tmp.path),
exitCode: 7,
truncated: true,
resource: { uri: "tool-output://opaque" },
outputPath: "/tmp/tool-output/tool_opaque",
})
expect(settled.outputPaths).toEqual(["/tmp/tool-output/tool_opaque"])
expect(truncations).toMatchObject([
{ sessionID, toolCallID: "call-overflow", content: "HEAD full output TAIL" },
])
expect(JSON.stringify(settled)).not.toContain(tmp.path + path.sep + "tool-output")
}),
),
)

View file

@ -11,7 +11,6 @@ import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir"
const sessionID = SessionV2.ID.make("ses_tool_output_store")
const otherSessionID = SessionV2.ID.make("ses_tool_output_store_other")
const withStore = <A, E, R>(
body: (input: { root: string; store: ToolOutputStore.Interface; fs: FSUtil.Interface }) => Effect.Effect<A, E, R>,
@ -44,140 +43,88 @@ const withStore = <A, E, R>(
const it = testEffect(Layer.empty)
describe("ToolOutputStore", () => {
it.live("returns under-limit text unchanged without writing a resource", () =>
it.live("returns under-limit text unchanged without writing a file", () =>
withStore(({ store }) =>
Effect.gen(function* () {
expect(yield* store.truncate({ sessionID, toolCallID: "call-short", content: "line one\nline two" })).toEqual({
content: "line one\nline two",
expect(yield* store.truncate({ sessionID, toolCallID: "call-short", content: "one\ntwo" })).toEqual({
content: "one\ntwo",
truncated: false,
})
}),
),
)
it.live("stores byte-truncated output and returns an opaque head-tail preview", () =>
withStore(({ store }) =>
it.live("stores full output at an absolute managed path", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const content = "HEAD-" + "x".repeat(100) + "-TAIL"
const result = yield* store.truncate({ sessionID, toolCallID: "call-bytes", content, maxBytes: 20 })
const content = "HEAD-" + "x".repeat(500) + "-TAIL"
const result = yield* store.truncate({ sessionID, toolCallID: "call-large", content, maxBytes: 300 })
expect(result.truncated).toBe(true)
if (!result.truncated) throw new Error("expected truncation")
expect(path.isAbsolute(result.outputPath)).toBe(true)
expect(result.outputPath).toStartWith(path.join(root, "tool-output", "tool_"))
expect(result.content).toContain(result.outputPath)
expect(result.content).toContain("HEAD-")
expect(result.content).toContain("-TAIL")
expect(result.content).toContain("output truncated")
expect(result.resource.uri).toMatch(/^tool-output:\/\/[0-9A-Za-z]+$/)
expect(result.resource.uri.slice("tool-output://".length)).not.toContain("/")
expect(result.resource.uri).not.toContain("\\")
expect(result.resource).toMatchObject({ mime: "text/plain", size: Buffer.byteLength(content) })
expect((yield* store.read({ sessionID, uri: result.resource.uri })).content).toBe(content)
expect(yield* fs.readFileString(result.outputPath)).toBe(content)
}),
),
)
it.live("stores line-truncated output and keeps both ends in the preview", () =>
withStore(({ store }) =>
it.live("bounds aggregate text blocks with one managed file", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const content = Array.from({ length: 10 }, (_, index) => `line-${index}`).join("\n")
const result = yield* store.truncate({ sessionID, toolCallID: "call-lines", content, maxLines: 4 })
expect(result.truncated).toBe(true)
if (!result.truncated) throw new Error("expected truncation")
expect(result.content).toContain("line-0\nline-1")
expect(result.content).toContain("line-8\nline-9")
expect(result.content).not.toContain("line-4")
}),
),
)
it.live("keeps one-line previews bounded", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const result = yield* store.truncate({
const first = "HEAD-" + "x".repeat(30_000)
const second = "y".repeat(30_000) + "-TAIL"
const result = yield* store.bound({
sessionID,
toolCallID: "call-one-line",
content: "one\ntwo\nthree",
maxLines: 1,
toolCallID: "call-aggregate",
output: {
structured: { kind: "report" },
content: [
{ type: "text", text: first },
{ type: "text", text: second },
],
},
})
expect(result.truncated).toBe(true)
if (!result.truncated) throw new Error("expected truncation")
const preview = result.content.split("\n\n... output truncated")[0]
expect(preview).toBe("one")
expect(result.output.structured).toEqual({ kind: "report" })
expect(result.outputPaths).toHaveLength(1)
expect(yield* fs.readFileString(result.outputPaths[0]!)).toBe(`${first}\n\n${second}`)
if (result.output.content[0]?.type !== "text") throw new Error("expected text preview")
expect(Buffer.byteLength(result.output.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES)
}),
),
)
it.live("pages reads within the bounded managed-resource limit", () =>
it.live("uses bounded text for oversized structured-only output", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) }
const result = yield* store.bound({ sessionID, toolCallID: "call-json", output: { structured, content: [] } })
expect(result.output.structured).toBe(structured)
expect(result.outputPaths).toHaveLength(1)
expect(yield* fs.readFileString(result.outputPaths[0]!)).toBe(JSON.stringify(structured))
}),
),
)
it.live("degrades to lossy bounded output when writing fails", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const resource = yield* store.write({
yield* fs.writeFileString(path.join(root, "tool-output"), "not a directory")
const result = yield* store.bound({
sessionID,
toolCallID: "call-page",
content: "0123456789",
name: "out.txt",
toolCallID: "call-lossy",
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
})
const first = yield* store.read({ sessionID, uri: resource.uri, limit: 4 })
const second = yield* store.read({ sessionID, uri: resource.uri, offset: first.next, limit: 4 })
const last = yield* store.read({ sessionID, uri: resource.uri, offset: second.next, limit: 4 })
expect(first).toMatchObject({ content: "0123", offset: 0, truncated: true, next: 4 })
expect(second).toMatchObject({ content: "4567", offset: 4, truncated: true, next: 8 })
expect(last).toMatchObject({ content: "89", offset: 8, truncated: false })
expect(last.resource).toEqual({ uri: resource.uri, mime: "text/plain", name: "out.txt", size: 10 })
expect(
JSON.parse(
yield* fs.readFileString(
path.join(root, "tool-output", "managed", `${resource.uri.slice("tool-output://".length)}.json`),
),
),
).toMatchObject({
sessionID,
toolCallID: "call-page",
})
const bounded = yield* store.read({
sessionID,
uri: (yield* store.write({
sessionID,
toolCallID: "call-bounded",
content: "x".repeat(ToolOutputStore.MAX_READ_BYTES + 10),
})).uri,
limit: ToolOutputStore.MAX_READ_BYTES + 10,
})
expect(Buffer.byteLength(bounded.content)).toBe(ToolOutputStore.MAX_READ_BYTES)
expect(bounded).toMatchObject({ truncated: true, next: ToolOutputStore.MAX_READ_BYTES })
expect(result.outputPaths).toEqual([])
if (result.output.content[0]?.type !== "text") throw new Error("expected text preview")
expect(result.output.content[0].text).toContain("could not be retained")
}),
),
)
it.live("allows the owning session and denies cross-session reads", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const resource = yield* store.write({ sessionID, toolCallID: "call-owned", content: "owned" })
expect((yield* store.read({ sessionID, uri: resource.uri })).content).toBe("owned")
expect(yield* Effect.flip(store.read({ sessionID: otherSessionID, uri: resource.uri }))).toBeInstanceOf(
ToolOutputStore.AccessDeniedError,
)
}),
),
)
it.live("rejects resources whose payload size no longer matches metadata", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const resource = yield* store.write({ sessionID, toolCallID: "call-modified", content: "original" })
const id = resource.uri.slice("tool-output://".length)
yield* fs.writeFileString(path.join(root, "tool-output", "managed", `${id}.txt`), "changed payload")
expect(yield* Effect.flip(store.read({ sessionID, uri: resource.uri }))).toBeInstanceOf(
ToolOutputStore.ResourceNotFoundError,
)
}),
),
)
it.live("honors configured truncation limits", () =>
it.live("honors configured limits", () =>
withStore(
({ store }) =>
Effect.gen(function* () {
@ -190,75 +137,19 @@ describe("ToolOutputStore", () => {
),
)
it.live("cleans old managed resources while preserving recent and unrelated files", () =>
it.live("cleans expired managed files and preserves unrelated files", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const old = yield* store.write({ sessionID, toolCallID: "call-old", content: "old" })
const recent = yield* store.write({ sessionID, toolCallID: "call-recent", content: "recent" })
const directory = path.join(root, "tool-output", "managed")
const oldID = old.uri.slice("tool-output://".length)
const recentID = recent.uri.slice("tool-output://".length)
const oldMetadata = path.join(directory, `${oldID}.json`)
const unrelated = path.join(root, "tool-output", "unrelated.txt")
const unrelatedManaged = path.join(directory, "unrelated.txt")
const record = JSON.parse(yield* fs.readFileString(oldMetadata))
yield* fs.writeFileString(
oldMetadata,
JSON.stringify({ ...record, created: Date.now() - 8 * 24 * 60 * 60 * 1_000 }),
)
const old = yield* store.write({ sessionID, toolCallID: "old", content: "old" })
const recent = yield* store.write({ sessionID, toolCallID: "recent", content: "recent" })
const unrelated = path.join(root, "tool-output", "keep.txt")
yield* fs.writeFileString(unrelated, "keep")
yield* fs.writeFileString(unrelatedManaged, "keep")
const expired = new Date(Date.now() - 8 * 24 * 60 * 60 * 1_000)
yield* fs.utimes(old, expired, expired)
yield* store.cleanup()
expect(yield* fs.exists(path.join(directory, `${oldID}.txt`))).toBe(false)
expect(yield* fs.exists(oldMetadata)).toBe(false)
expect(yield* fs.exists(path.join(directory, `${recentID}.txt`))).toBe(true)
expect(yield* fs.exists(old)).toBe(false)
expect(yield* fs.exists(recent)).toBe(true)
expect(yield* fs.exists(unrelated)).toBe(true)
expect(yield* fs.exists(unrelatedManaged)).toBe(true)
}),
),
)
it.live("cleans stale generated orphan payloads and malformed pairs", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const directory = path.join(root, "tool-output", "managed")
yield* fs.ensureDir(directory)
const orphanID = "00000000000000000000000000"
const malformedID = "00000000000000000000000001"
const orphan = path.join(directory, `${orphanID}.txt`)
const malformedPayload = path.join(directory, `${malformedID}.txt`)
const malformedMetadata = path.join(directory, `${malformedID}.json`)
yield* fs.writeFileString(orphan, "orphan")
yield* fs.writeFileString(malformedPayload, "malformed")
yield* fs.writeFileString(malformedMetadata, "not json")
const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1_000)
yield* Effect.all([fs.utimes(orphan, old, old), fs.utimes(malformedPayload, old, old)])
yield* store.cleanup()
expect(yield* fs.exists(orphan)).toBe(false)
expect(yield* fs.exists(malformedPayload)).toBe(false)
expect(yield* fs.exists(malformedMetadata)).toBe(false)
}),
),
)
it.live("cleans managed resources whose payload size no longer matches metadata", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const resource = yield* store.write({ sessionID, toolCallID: "call-modified", content: "original" })
const directory = path.join(root, "tool-output", "managed")
const id = resource.uri.slice("tool-output://".length)
const payload = path.join(directory, `${id}.txt`)
const metadata = path.join(directory, `${id}.json`)
yield* fs.writeFileString(payload, "changed payload")
yield* store.cleanup()
expect(yield* fs.exists(payload)).toBe(false)
expect(yield* fs.exists(metadata)).toBe(false)
}),
),
)

View file

@ -5,7 +5,6 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ReadTool } from "@opencode-ai/core/tool/read"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { RelativePath } from "@opencode-ai/core/schema"
import { testEffect } from "./lib/effect"
@ -21,7 +20,6 @@ let listReal = "/project/src"
let size = 5
let real = "/project/README.md"
let afterApproval = () => {}
const resourceReads: ToolOutputStore.ReadInput[] = []
const filesystem = Layer.succeed(
FileSystem.Service,
FileSystem.Service.of({
@ -128,32 +126,8 @@ const permission = Layer.succeed(
}),
)
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const resources = Layer.succeed(
ToolOutputStore.Service,
ToolOutputStore.Service.of({
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: () => Effect.die("unused"),
cleanup: () => Effect.die("unused"),
read: (input) =>
Effect.sync(() => {
resourceReads.push(input)
return new ToolOutputStore.Page({
resource: new ToolOutputStore.Resource({ uri: input.uri, mime: "text/plain", size: 5 }),
content: "hello",
offset: input.offset ?? 0,
truncated: false,
})
}),
}),
)
const read = ReadTool.layer.pipe(
Layer.provide(registry),
Layer.provide(filesystem),
Layer.provide(permission),
Layer.provide(resources),
)
const it = testEffect(Layer.mergeAll(registry, filesystem, permission, resources, read))
const read = ReadTool.layer.pipe(Layer.provide(registry), Layer.provide(filesystem), Layer.provide(permission))
const it = testEffect(Layer.mergeAll(registry, filesystem, permission, read))
const sessionID = SessionV2.ID.make("ses_read_tool_test")
describe("ReadTool", () => {
@ -205,36 +179,6 @@ describe("ReadTool", () => {
}),
)
it.effect("reads an opaque managed resource without treating it as a path", () =>
Effect.gen(function* () {
resourceReads.length = 0
assertions.length = 0
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: {
type: "tool-call",
id: "call-read-resource",
name: "read",
input: { resource: "tool-output://opaque", offset: 2, limit: 10 },
},
}),
).toEqual({
type: "json",
value: {
resource: { uri: "tool-output://opaque", mime: "text/plain", size: 5 },
content: "hello",
offset: 2,
truncated: false,
},
})
expect(resourceReads).toEqual([{ sessionID, uri: "tool-output://opaque", offset: 2, limit: 10 }])
expect(assertions).toEqual([])
}),
)
it.effect("lists a bounded directory page through read", () =>
Effect.gen(function* () {
assertions.length = 0

View file

@ -83,7 +83,7 @@ describe("SkillTool", () => {
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
read: () => Effect.die("unused"),
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
cleanup: () => Effect.die("unused"),
}),
)
@ -117,13 +117,9 @@ describe("SkillTool", () => {
])
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content available as tool-output://opaque ...\n\nTAIL",
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
truncated: true,
resource: new ToolOutputStore.Resource({
uri: "tool-output://opaque",
mime: "text/plain",
size: input.content.length,
}),
outputPath: "/tmp/tool-output/tool_opaque",
})
expect(
yield* registry.settle({
@ -131,9 +127,9 @@ describe("SkillTool", () => {
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { name: "effect" } },
}),
).toMatchObject({
result: { type: "text", value: expect.stringContaining("tool-output://opaque") },
result: { type: "text", value: expect.stringContaining("/tmp/tool-output/tool_opaque") },
output: {
structured: { truncated: true, resource: { uri: "tool-output://opaque" } },
structured: { truncated: true, outputPath: "/tmp/tool-output/tool_opaque" },
},
})
expect(assertions).toEqual([

View file

@ -44,7 +44,7 @@ const resources = Layer.succeed(
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
read: () => Effect.die("unused"),
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
cleanup: () => Effect.die("unused"),
}),
)
@ -187,26 +187,25 @@ describe("WebFetchTool contribution", () => {
}),
)
it.effect("exposes managed overflow through an opaque resource URI", () =>
it.effect("exposes managed overflow through a path", () =>
Effect.gen(function* () {
reset()
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content available as tool-output://opaque ...\n\nTAIL",
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
truncated: true,
resource: new ToolOutputStore.Resource({
uri: "tool-output://opaque",
mime: input.mime ?? "text/plain",
size: input.content.length,
}),
outputPath: "/tmp/tool-output/tool_opaque",
})
const registry = yield* ToolRegistry.Service
const settled = yield* registry.settle(call({ url: "https://1.1.1.1", format: "html" }, "call-overflow"))
expect(settled.result).toMatchObject({ type: "text", value: expect.stringContaining("tool-output://opaque") })
expect(settled.result).toMatchObject({
type: "text",
value: expect.stringContaining("/tmp/tool-output/tool_opaque"),
})
expect(settled.output?.structured).toMatchObject({
truncated: true,
resource: { uri: "tool-output://opaque", mime: "text/html" },
outputPath: "/tmp/tool-output/tool_opaque",
})
expect(truncations).toEqual([{ sessionID, toolCallID: "call-overflow", content: "hello", mime: "text/html" }])
}),

View file

@ -123,7 +123,7 @@ const resources = Layer.succeed(
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
read: () => Effect.die("unused"),
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
cleanup: () => Effect.die("unused"),
}),
)
@ -287,13 +287,9 @@ describe("WebSearchTool contribution", () => {
config = { provider: "exa", enableExa: false, enableParallel: false }
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content available as tool-output://opaque ...\n\nTAIL",
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
truncated: true,
resource: new ToolOutputStore.Resource({
uri: "tool-output://opaque",
mime: "text/plain",
size: input.content.length,
}),
outputPath: "/tmp/tool-output/tool_opaque",
})
const registry = yield* ToolRegistry.Service
@ -302,11 +298,14 @@ describe("WebSearchTool contribution", () => {
call: { type: "tool-call", id: "call-overflow", name: "websearch", input: { query: "verbose" } },
})
expect(settled.result).toMatchObject({ type: "text", value: expect.stringContaining("tool-output://opaque") })
expect(settled.result).toMatchObject({
type: "text",
value: expect.stringContaining("/tmp/tool-output/tool_opaque"),
})
expect(settled.output?.structured).toMatchObject({
provider: "exa",
truncated: true,
resource: { uri: "tool-output://opaque", mime: "text/plain" },
outputPath: "/tmp/tool-output/tool_opaque",
})
expect(truncations).toEqual([{ sessionID, toolCallID: "call-overflow", content: "full search results" }])
}),

View file

@ -1120,6 +1120,7 @@ export type GlobalEvent = {
[key: string]: unknown
}
content: Array<ToolTextContent | ToolFileContent>
outputPaths?: Array<string>
result?: unknown
provider: {
executed: boolean
@ -3644,6 +3645,7 @@ export type SyncEventSessionNextToolSuccess = {
[key: string]: unknown
}
content: Array<ToolTextContent | ToolFileContent>
outputPaths?: Array<string>
result?: unknown
provider: {
executed: boolean
@ -3957,6 +3959,7 @@ export type SessionMessageToolStateCompleted = {
}
attachments?: Array<PromptFileAttachment>
content: Array<ToolTextContent | ToolFileContent>
outputPaths?: Array<string>
structured: {
[key: string]: unknown
}
@ -4716,6 +4719,7 @@ export type EventSessionNextToolSuccess = {
[key: string]: unknown
}
content: Array<ToolTextContent | ToolFileContent>
outputPaths?: Array<string>
result?: unknown
provider: {
executed: boolean