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
parent 760d523847
commit a9094fd059
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 390 additions and 555 deletions

View File

@ -39,6 +39,12 @@ An expected temporary inability to observe a **Context Source** value; the runti
**Safe Provider-Turn Boundary**:
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
**Model Tool Output**:
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
**Managed Tool Output File**:
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
**Model Request Options**:
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
_Avoid_: Request body, wire options
@ -96,6 +102,17 @@ Provider-neutral sampling and output controls, partitioned from provider semanti
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
- A truncated **Model Tool Output** identifies its complete text both in the bounded model-visible preview and as a typed managed output path. Managed output paths do not modify the tool's validated structured result.
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
- Failure to retain a **Managed Tool Output File** does not change a successful tool operation into a failed one. The Session records an explicitly lossy bounded output without a path, while operators receive diagnostics for the storage failure.
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
## Example dialogue

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

View File

@ -178,28 +178,26 @@ Compatibility:
- Tool results are durably settled before provider continuation.
- Legacy text, JSON, and inline-media results remain convertible; unresolved URL and file sources must be materialized or explicitly rejected before provider lowering.
### Managed Tool-Output Resources
### Managed Tool-Output Files
Affected schema:
- New `ToolOutputStore.Resource` and `ToolOutputStore.Page` schemas.
- New `tool-output://<opaque-id>` URI contract.
- `read` tool resource-page input.
- New optional managed `outputPath` and `outputPaths` fields on tool results and completed Session tool state.
- Absolute managed output paths accepted by ordinary `read` and `grep` inputs.
Change:
- Spill oversized model-facing tool text into Session-owned opaque managed resources.
- Page stored UTF-8 content by byte offset with bounded reads and explicit `truncated` and `next` metadata.
- Spill oversized model-facing tool text into globally unique files under OpenCode's shared tool-output directory.
- Include the absolute file path in the bounded preview so ordinary `read`, `grep`, and `bash` operations can inspect it.
Reason:
- Tool results need bounded model context without discarding the full output.
- Opaque Session ownership prevents one Session from reading another Session's managed output.
- Filesystem resolution admits only direct generated `tool_*` files from the managed directory, while existing permissions whitelist that directory.
Compatibility:
- This is an additive internal and model-facing resource contract.
- Managed output is retained for a bounded period and is not a public filesystem path.
- Managed output is retained for a bounded period and exposed as a normal host filesystem path.
### Location-Scoped Filesystem Read And Search Contracts

View File

@ -200,6 +200,7 @@ The first V2 `apply_patch` leaf supports add, update, and delete hunks. It parse
- Revisit additional covering indexes as larger-history query shapes become concrete.
- Expose replayable Session events over HTTP and the generated SDK where remote consumers need them, deciding whether that public cursor should be opaque rather than the embedded API's branded aggregate sequence.
- Decide whether UI-facing Session subscriptions should optionally interleave ephemeral deltas while connected without advancing the durable cursor.
- Add provider-aware context control for provider-executed tool results. Generic text truncation cannot replace provider-native structured payloads that must round-trip exactly.
## Remove Dedicated `session.init` Route