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**: **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. 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**: **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. 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 _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. - 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. - 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. - 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 ## Example dialogue

View File

@ -13,9 +13,10 @@ import { ProjectReference } from "./project-reference"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { Protected } from "./filesystem/protected" import { Protected } from "./filesystem/protected"
import { Ripgrep } from "./filesystem/ripgrep" import { Ripgrep } from "./filesystem/ripgrep"
import { ToolOutputStore } from "./tool-output-store"
export const ReadInput = Schema.Struct({ export const ReadInput = Schema.Struct({
path: RelativePath, path: Schema.String,
reference: Schema.NonEmptyString.pipe(Schema.optional), reference: Schema.NonEmptyString.pipe(Schema.optional),
}) })
export type ReadInput = typeof ReadInput.Type export type ReadInput = typeof ReadInput.Type
@ -65,7 +66,7 @@ export class ReadTarget extends Schema.Class<ReadTarget>("FileSystem.ReadTarget"
}) {} }) {}
export const ListInput = Schema.Struct({ export const ListInput = Schema.Struct({
path: RelativePath.pipe(Schema.optional), path: Schema.String.pipe(Schema.optional),
reference: Schema.NonEmptyString.pipe(Schema.optional), reference: Schema.NonEmptyString.pipe(Schema.optional),
}) })
export type ListInput = typeof ListInput.Type export type ListInput = typeof ListInput.Type
@ -181,6 +182,7 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const location = yield* Location.Service const location = yield* Location.Service
const global = yield* Effect.serviceOption(Global.Service)
const references = yield* ProjectReference.Service const references = yield* ProjectReference.Service
const ripgrep = yield* Ripgrep.Service const ripgrep = yield* Ripgrep.Service
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie) 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) 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) } return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) }
}) })
const resolve = Effect.fnUntraced(function* (input?: RelativePath, reference?: string) { const resolve = Effect.fnUntraced(function* (input?: string, reference?: string) {
if (input && path.isAbsolute(input)) return yield* Effect.die(new Error("Path must be relative to the location")) 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 selected = yield* select(reference)
const absolute = path.resolve(selected.directory, input ?? ".") const absolute = path.resolve(selected.directory, input ?? ".")
if (!FSUtil.contains(selected.directory, absolute)) 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", { export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => { lookup: (ref: Location.Ref) => {
const location = Location.layer(ref) const location = Location.layer(ref)
const permissionsAndTools = ToolRegistry.layer.pipe(Layer.provideMerge(PermissionV2.locationLayer))
const systemContext = SystemContextBuiltIns.locationLayer const systemContext = SystemContextBuiltIns.locationLayer
const services = Layer.mergeAll( const base = Layer.mergeAll(
location, location,
Policy.locationLayer, Policy.locationLayer,
Config.locationLayer, Config.locationLayer,
@ -63,13 +62,18 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Pty.locationLayer, Pty.locationLayer,
SkillV2.locationLayer, SkillV2.locationLayer,
systemContext, systemContext,
permissionsAndTools,
LocationMutation.locationLayer.pipe(Layer.orDie), LocationMutation.locationLayer.pipe(Layer.orDie),
).pipe(Layer.provideMerge(location)) ).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 commits = FileMutation.locationLayer.pipe(Layer.provide(services))
const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services)) const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services))
const skillGuidance = SkillGuidance.locationLayer.pipe(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 todos = SessionTodo.layer.pipe(Layer.provide(services))
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services)) const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
const builtInTools = BuiltInTools.locationLayer.pipe( 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)) export const ResultLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_RESULT_LIMIT))
const RootInput = { const RootInput = {
path: RelativePath.pipe(Schema.optional), path: Schema.String.pipe(Schema.optional),
reference: Schema.NonEmptyString.pipe(Schema.optional), reference: Schema.NonEmptyString.pipe(Schema.optional),
} }

View File

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

View File

@ -308,6 +308,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
input: match.state.input, input: match.state.input,
structured: event.data.structured, structured: event.data.structured,
content: [...event.data.content], content: [...event.data.content],
outputPaths: event.data.outputPaths ? [...event.data.outputPaths] : [],
result: event.data.result, 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), input: Schema.Record(Schema.String, Schema.Unknown),
attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional), attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional),
content: ToolOutput.Content.pipe(Schema.Array), content: ToolOutput.Content.pipe(Schema.Array),
outputPaths: SessionEvent.Tool.Success.data.fields.outputPaths,
structured: ToolOutput.Structured, structured: ToolOutput.Structured,
result: SessionEvent.Tool.Success.data.fields.result, 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 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))) if (!(yield* SessionContextEpoch.current(db, session.id, agent.id, system.revision)))
return yield* Effect.die(new RetryTurn(undefined)) return yield* Effect.die(new RetryTurn(undefined))
const providerStream = llm.stream(request).pipe( const providerStream = llm.stream(request).pipe(
@ -216,12 +217,14 @@ export const layer = Layer.effect(
yield* publish(event) yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return if (event.type !== "tool-call" || event.providerExecuted) return
needsContinuation = true needsContinuation = true
yield* tools.settle({ sessionID: session.id, agent: agent.id, call: event }).pipe( yield* Effect.uninterruptibleMask((restore) =>
restore(tools.settle({ sessionID: session.id, agent: agent.id, call: event })).pipe(
Effect.catchCause((cause) => { Effect.catchCause((cause) => {
if (isQuestionRejected(cause)) return Effect.failCause(cause) if (isQuestionRejected(cause) || Cause.hasInterrupts(cause)) return Effect.failCause(cause)
return Effect.succeed({ return Effect.succeed({
result: { type: "error" as const, value: String(Cause.squash(cause)) }, result: { type: "error" as const, value: String(Cause.squash(cause)) },
output: undefined, output: undefined,
outputPaths: [],
}) })
}), }),
Effect.flatMap((settlement) => Effect.flatMap((settlement) =>
@ -232,10 +235,11 @@ export const layer = Layer.effect(
result: settlement.result, result: settlement.result,
output: settlement.output, output: settlement.output,
}), }),
settlement.outputPaths ?? [],
), ),
), ),
FiberSet.run(toolFibers), ),
) ).pipe(FiberSet.run(toolFibers))
}), }),
), ),
Effect.ensuring(withPublication(publisher.flush())), 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) { switch (event.type) {
case "step-start": case "step-start":
yield* startAssistant() yield* startAssistant()
@ -347,6 +350,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
callID: event.id, callID: event.id,
...result, ...result,
outputPaths,
result: event.result, result: event.result,
provider, provider,
}) })

View File

@ -1,57 +1,19 @@
export * as ToolOutputStore from "./tool-output-store" export * as ToolOutputStore from "./tool-output-store"
import path from "path" import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" import { Context, Duration, Effect, Layer, Option, Schedule } from "effect"
import { Config } from "./config" import { Config } from "./config"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
import { Global } from "./global" import { Global } from "./global"
import { NonNegativeInt, PositiveInt } from "./schema"
import { SessionSchema } from "./session/schema" import { SessionSchema } from "./session/schema"
import { Identifier } from "./util/identifier" import { Identifier } from "./util/identifier"
import type { ToolOutput } from "@opencode-ai/llm"
export const MAX_LINES = 2_000 export const MAX_LINES = 2_000
export const MAX_BYTES = 50 * 1024 export const MAX_BYTES = 50 * 1024
export const MAX_READ_BYTES = 50 * 1024
export const RETENTION = Duration.days(7) export const RETENTION = Duration.days(7)
const URI_PREFIX = "tool-output://" export const MANAGED_DIRECTORY = "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 interface WriteInput { export interface WriteInput {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
@ -66,70 +28,31 @@ export interface TruncateInput extends WriteInput {
readonly maxBytes?: number 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 = export type TruncateResult =
| { readonly content: string; readonly truncated: false } | { 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 { export interface BoundInput {
readonly version: 1 readonly sessionID: SessionSchema.ID
readonly id: string
readonly uri: string
readonly sessionID: string
readonly toolCallID: string readonly toolCallID: string
readonly mime: string readonly output: ToolOutput
readonly name?: string }
readonly size: number
readonly created: number export interface BoundResult {
readonly output: ToolOutput
readonly outputPaths: ReadonlyArray<string>
} }
export interface Interface { export interface Interface {
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }> 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 truncate: (input: TruncateInput) => Effect.Effect<TruncateResult>
readonly read: ( readonly bound: (input: BoundInput) => Effect.Effect<BoundResult>
input: ReadInput,
) => Effect.Effect<Page, AccessDeniedError | InvalidResourceError | ResourceNotFoundError>
readonly cleanup: () => Effect.Effect<void> readonly cleanup: () => Effect.Effect<void>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolOutputStore") {} 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) => { const takePrefix = (input: string, maximumBytes: number) => {
let bytes = 0 let bytes = 0
let content = "" let content = ""
@ -178,6 +101,14 @@ const preview = (text: string, maxLines: number, maxBytes: number) => {
return { head: takePrefix(sampled, headBytes), tail: takeSuffix(sampled, tailBytes) } 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( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@ -185,21 +116,6 @@ export const layer = Layer.effect(
const global = yield* Global.Service const global = yield* Global.Service
const config = yield* Effect.serviceOption(Config.Service) const config = yield* Effect.serviceOption(Config.Service)
const directory = path.join(global.data, MANAGED_DIRECTORY) 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* () { const limits = Effect.fn("ToolOutputStore.limits")(function* () {
if (Option.isNone(config)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES } 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 write = Effect.fn("ToolOutputStore.write")(function* (input: WriteInput) {
const id = Identifier.ascending() const file = path.join(directory, `tool_${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(),
}
yield* fs.ensureDir(directory).pipe(Effect.orDie) yield* fs.ensureDir(directory).pipe(Effect.orDie)
yield* fs.writeFileString(contentPath(id), input.content, { flag: "wx" }).pipe(Effect.orDie) yield* fs.writeFileString(file, input.content, { flag: "wx" }).pipe(Effect.orDie)
yield* fs.writeFileString(metadataPath(id), JSON.stringify(record), { flag: "wx" }).pipe( return file
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,
})
}) })
const truncate = Effect.fn("ToolOutputStore.truncate")(function* (input: TruncateInput) { 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) { if (input.content.split("\n").length <= maxLines && Buffer.byteLength(input.content, "utf-8") <= maxBytes) {
return { content: input.content, truncated: false } as const return { content: input.content, truncated: false } as const
} }
const resource = yield* write(input) const outputPath = yield* write(input)
const bounded = preview(input.content, maxLines, maxBytes) const marker = `... output truncated; full content saved to ${outputPath} ...`
const marker = `... output truncated; full content available as ${resource.uri} ...`
return { 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, truncated: true,
resource, outputPath,
} as const } as const
}) })
const read = Effect.fn("ToolOutputStore.read")(function* (input: ReadInput) { const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
const record = yield* load(input.uri) const text = input.output.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n\n")
if (record.sessionID !== input.sessionID) { const structured = yield* Effect.sync(() => JSON.stringify(input.output.structured)).pipe(
return yield* Effect.fail(new AccessDeniedError({ uri: input.uri, sessionID: input.sessionID })) Effect.catch(() => Effect.succeed(String(input.output.structured))),
}
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())
}),
) )
let start = 0 const content = text || input.output.content.length > 0 ? text : structured
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++ if (content === undefined) return { output: input.output, outputPaths: [] }
let end = Math.min(start + limit, bytes.length)
while (end > start && end < bytes.length && (bytes[end] & 0xc0) === 0x80) end-- const truncated = yield* truncate({
if (end === start && end < bytes.length) { sessionID: input.sessionID,
end = Math.min(start + limit, bytes.length) toolCallID: input.toolCallID,
while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end++ 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,
} }
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 } : {}), 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 cleanup = Effect.fn("ToolOutputStore.cleanup")(function* () { const cleanup = Effect.fn("ToolOutputStore.cleanup")(function* () {
const entries = yield* fs.readDirectory(directory).pipe(Effect.catch(() => Effect.succeed([]))) const entries = yield* fs.readDirectory(directory).pipe(Effect.catch(() => Effect.succeed([])))
const cutoff = Date.now() - Duration.toMillis(RETENTION) const cutoff = Date.now() - Duration.toMillis(RETENTION)
const ids = new Set( for (const entry of entries) {
entries.flatMap((entry) => { if (!entry.startsWith("tool_")) continue
const match = entry.match(/^([0-9a-f]{12}[0-9A-Za-z]{14})\.(?:json|txt)$/) const file = path.join(directory, entry)
return match ? [match[1]] : [] const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.void))
}), const modified = info?.mtime.pipe(
)
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.map((date) => date.getTime()),
Option.getOrElse(() => 0), Option.getOrElse(() => 0),
) )
: 0 if (modified !== undefined && modified < cutoff) yield* fs.remove(file).pipe(Effect.catch(() => Effect.void))
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
)
yield* removePair(id)
} }
}) })
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, truncated: Schema.Boolean,
stdoutTruncated: Schema.Boolean.pipe(Schema.optional), stdoutTruncated: Schema.Boolean.pipe(Schema.optional),
stderrTruncated: 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), timedOut: Schema.Boolean.pipe(Schema.optional),
warnings: Schema.Array(Schema.String).pipe(Schema.optional), warnings: Schema.Array(Schema.String).pipe(Schema.optional),
}) })
@ -121,6 +121,7 @@ export const layer = Layer.effectDiscard(
yield* registry.contribute((editor) => yield* registry.contribute((editor) =>
editor.set(name, { editor.set(name, {
tool: definition, tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
execute: ({ parameters, sessionID, call, assertPermission }) => execute: ({ parameters, sessionID, call, assertPermission }) =>
Effect.gen(function* () { Effect.gen(function* () {
const plan = yield* mutation.resolve({ path: parameters.workdir ?? ".", kind: "directory" }) const plan = yield* mutation.resolve({ path: parameters.workdir ?? ".", kind: "directory" })
@ -187,7 +188,7 @@ export const layer = Layer.effectDiscard(
...(result.stdoutTruncated ? { stdoutTruncated: true } : {}), ...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
...(result.stderrTruncated ? { stderrTruncated: true } : {}), ...(result.stderrTruncated ? { stderrTruncated: true } : {}),
...(truncated.truncated && !result.stdoutTruncated && !result.stderrTruncated ...(truncated.truncated && !result.stdoutTruncated && !result.stderrTruncated
? { resource: truncated.resource } ? { outputPath: truncated.outputPath }
: {}), : {}),
} }
}).pipe( }).pipe(

View File

@ -53,7 +53,7 @@ export const toModelOutput = (output: Success) => {
const definition = Tool.make({ const definition = Tool.make({
description: 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, parameters: Parameters,
success: LocationSearch.GrepResult, success: LocationSearch.GrepResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], 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 { Tool, ToolFailure } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect" import { Cause, Effect, Layer, Schema } from "effect"
import { FileSystem } from "../filesystem" import { FileSystem } from "../filesystem"
import { NonNegativeInt, PositiveInt } from "../schema"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { ToolOutputStore } from "../tool-output-store"
import { ToolRegistry } from "./registry" import { ToolRegistry } from "./registry"
export const name = "read" export const name = "read"
@ -18,17 +16,12 @@ const LocationInput = Schema.Struct({
description: "The maximum number of directory entries or text lines to read", description: "The maximum number of directory entries or text lines to read",
}), }),
}) })
const ResourceInput = Schema.Struct({ const Input = LocationInput
resource: Schema.String, const Success = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage])
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 definition = Tool.make({ const definition = Tool.make({
description: 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, parameters: Input,
success: Success, success: Success,
}) })
@ -37,7 +30,6 @@ export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const filesystem = yield* FileSystem.Service const filesystem = yield* FileSystem.Service
const resources = yield* ToolOutputStore.Service
yield* registry.contribute((editor) => yield* registry.contribute((editor) =>
editor.set(name, { editor.set(name, {
@ -45,8 +37,6 @@ export const layer = Layer.effectDiscard(
execute: ({ parameters, sessionID, assertPermission }) => { execute: ({ parameters, sessionID, assertPermission }) => {
const input = parameters const input = parameters
return Effect.gen(function* () { 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) const resolved = yield* filesystem.resolveReadPath(input)
if (resolved.type === "directory") { if (resolved.type === "directory") {
const { offset, limit } = input const { offset, limit } = input
@ -81,7 +71,7 @@ export const layer = Layer.effectDiscard(
Effect.catchCause((cause) => Effect.catchCause((cause) =>
Effect.fail( Effect.fail(
new ToolFailure({ new ToolFailure({
message: `Unable to read ${"resource" in input ? input.resource : input.path}`, message: `Unable to read ${input.path}`,
error: Cause.squash(cause), error: Cause.squash(cause),
}), }),
), ),
@ -96,5 +86,4 @@ export const locationLayer = layer.pipe(
Layer.provideMerge(ToolRegistry.defaultLayer), Layer.provideMerge(ToolRegistry.defaultLayer),
Layer.provideMerge(FileSystem.locationLayer), Layer.provideMerge(FileSystem.locationLayer),
Layer.provideMerge(PermissionV2.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 { SessionSchema } from "../session/schema"
import type { SessionV2 } from "../session" import type { SessionV2 } from "../session"
import { ApplicationTools } from "./application-tools" import { ApplicationTools } from "./application-tools"
import { ToolOutputStore } from "../tool-output-store"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
export type ExecuteInput = { export type ExecuteInput = {
@ -57,6 +58,7 @@ export type Entry<
readonly execute?: ( readonly execute?: (
input: AuthorizeInput<Schema.Schema.Type<Parameters>>, input: AuthorizeInput<Schema.Schema.Type<Parameters>>,
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure> ) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
readonly outputPaths?: (output: Schema.Schema.Type<Success>) => ReadonlyArray<string>
} }
type Data = { type Data = {
@ -78,7 +80,11 @@ export interface Interface {
readonly contribute: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope> readonly contribute: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
readonly definitions: () => Effect.Effect<ReadonlyArray<ReturnType<typeof Tool.toDefinitions>[number]>> readonly definitions: () => Effect.Effect<ReadonlyArray<ReturnType<typeof Tool.toDefinitions>[number]>>
readonly execute: (input: ExecuteInput) => Effect.Effect<ToolResultValue> 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") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
@ -90,6 +96,7 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
const applications = yield* ApplicationTools.Service const applications = yield* ApplicationTools.Service
const resources = yield* ToolOutputStore.Service
const state = State.create<Data, Editor>({ const state = State.create<Data, Editor>({
initial: () => ({ entries: new Map() }), initial: () => ({ entries: new Map() }),
editor: (draft) => ({ editor: (draft) => ({
@ -162,12 +169,16 @@ export const layer = Layer.effect(
), ),
), ),
), ),
Effect.map((value): ToolSettlement => { Effect.map((value): Settlement => {
const settled = (() => {
if (entry.tool._legacyResult && ToolResult.is(value)) if (entry.tool._legacyResult && ToolResult.is(value))
return { result: value, output: ToolOutput.fromResultValue(value) } return { result: value, output: ToolOutput.fromResultValue(value) }
const output = entry.tool._project(parameters, input.call.id, value) const output = entry.tool._project(parameters, input.call.id, value)
const result = ToolOutput.toResultValue(output) const result = ToolOutput.toResultValue(output)
return result.type === "error" ? { result } : { result, 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) { const execute = Effect.fn("ToolRegistry.execute")(function* (input: ExecuteInput) {
return (yield* settle(input)).result 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, directory: Schema.String,
output: Schema.String, output: Schema.String,
truncated: Schema.Boolean, truncated: Schema.Boolean,
resource: ToolOutputStore.Resource.pipe(Schema.optional), outputPath: Schema.String.pipe(Schema.optional),
}) })
export const description = [ export const description = [
@ -73,6 +73,7 @@ export const layer = Layer.effectDiscard(
yield* registry.contribute((editor) => yield* registry.contribute((editor) =>
editor.set(name, { editor.set(name, {
tool: definition, tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
execute: ({ parameters, sessionID, call, assertPermission }) => execute: ({ parameters, sessionID, call, assertPermission }) =>
Effect.gen(function* () { Effect.gen(function* () {
const current = yield* skills.list() const current = yield* skills.list()
@ -98,7 +99,7 @@ export const layer = Layer.effectDiscard(
directory, directory,
output: output.content, output: output.content,
truncated: output.truncated, 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))))) }).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. 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)) 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, format: Parameters.fields.format,
output: Schema.String, output: Schema.String,
truncated: Schema.Boolean, truncated: Schema.Boolean,
resource: ToolOutputStore.Resource.pipe(Schema.optional), outputPath: Schema.String.pipe(Schema.optional),
}) })
type Format = (typeof Parameters.Type)["format"] type Format = (typeof Parameters.Type)["format"]
@ -141,6 +141,7 @@ export const layer = Layer.effectDiscard(
yield* registry.contribute((editor) => yield* registry.contribute((editor) =>
editor.set(name, { editor.set(name, {
tool: definition, tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
execute: ({ parameters, sessionID, call, assertPermission }) => execute: ({ parameters, sessionID, call, assertPermission }) =>
Effect.gen(function* () { Effect.gen(function* () {
const parsed = new URL(parameters.url) const parsed = new URL(parameters.url)
@ -178,7 +179,7 @@ export const layer = Layer.effectDiscard(
format: parameters.format, format: parameters.format,
output: truncated.content, output: truncated.content,
truncated: truncated.truncated, truncated: truncated.truncated,
...(truncated.truncated ? { resource: truncated.resource } : {}), ...(truncated.truncated ? { outputPath: truncated.outputPath } : {}),
} }
}).pipe( }).pipe(
Effect.catchCause((cause) => Effect.catchCause((cause) =>

View File

@ -179,7 +179,7 @@ const Success = Schema.Struct({
provider: Provider, provider: Provider,
text: Schema.String, text: Schema.String,
truncated: Schema.Boolean, truncated: Schema.Boolean,
resource: ToolOutputStore.Resource.pipe(Schema.optional), outputPath: Schema.String.pipe(Schema.optional),
}) })
const definition = Tool.make({ const definition = Tool.make({
@ -199,6 +199,7 @@ export const layer = Layer.effectDiscard(
yield* registry.contribute((editor) => yield* registry.contribute((editor) =>
editor.set(name, { editor.set(name, {
tool: definition, tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
execute: ({ parameters, sessionID, call, assertPermission }) => { execute: ({ parameters, sessionID, call, assertPermission }) => {
const provider = selectProvider(sessionID, config, config.provider) const provider = selectProvider(sessionID, config, config.provider)
return Effect.gen(function* () { return Effect.gen(function* () {
@ -239,7 +240,7 @@ export const layer = Layer.effectDiscard(
provider, provider,
text: truncated.content, text: truncated.content,
truncated: truncated.truncated, truncated: truncated.truncated,
...(truncated.truncated ? { resource: truncated.resource } : {}), ...(truncated.truncated ? { outputPath: truncated.outputPath } : {}),
} }
}).pipe( }).pipe(
Effect.catchCause((cause) => 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 { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Effect, Exit, Layer, Schema, Scope } from "effect" import { Effect, Exit, Layer, Schema, Scope } from "effect"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
@ -11,7 +12,11 @@ const permission = Layer.mock(PermissionV2.Service, {
assert: () => Effect.void, assert: () => Effect.void,
}) })
const applications = ApplicationTools.layer 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 it = testEffect(Layer.mergeAll(applications, registry))
const sessionID = SessionV2.ID.make("ses_application_tool") 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 { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { ProjectReference } from "@opencode-ai/core/project-reference" import { ProjectReference } from "@opencode-ai/core/project-reference"
import { Repository } from "@opencode-ai/core/repository" import { Repository } from "@opencode-ai/core/repository"
import { Global } from "@opencode-ai/core/global"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location" import { location } from "./fixture/location"
@ -22,7 +23,12 @@ const inertReferences = ProjectReference.Service.of({
containsManagedPath: () => Effect.succeed(false), 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( return Effect.provide(
FileSystem.layer.pipe( FileSystem.layer.pipe(
Layer.provide( Layer.provide(
@ -31,6 +37,7 @@ function provide(directory: string, references = inertReferences, filesystem = F
Ripgrep.defaultLayer, Ripgrep.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Layer.succeed(ProjectReference.Service, references), 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", () => { 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", () => it.live("reads text and binary files", () =>
withTmp((directory) => withTmp((directory) =>
Effect.gen(function* () { 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 { ProjectReference } from "@opencode-ai/core/project-reference"
import { Ripgrep } from "@opencode-ai/core/ripgrep" import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/core/global"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { it } from "./lib/effect" import { it } from "./lib/effect"
const inertReferences = references({}) const inertReferences = references({})
function provide(directory: string, projectReferences = inertReferences) { function provide(directory: string, projectReferences = inertReferences, data = Global.Path.data) {
const dependencies = Layer.mergeAll( const dependencies = Layer.mergeAll(
FSUtil.defaultLayer, FSUtil.defaultLayer,
FileSystemRipgrep.defaultLayer, FileSystemRipgrep.defaultLayer,
AppProcess.defaultLayer, AppProcess.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Layer.succeed(ProjectReference.Service, projectReferences), Layer.succeed(ProjectReference.Service, projectReferences),
Global.layerWith({ data }),
) )
const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies)) const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
const search = LocationSearch.layer.pipe( const search = LocationSearch.layer.pipe(
@ -43,6 +45,21 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
} }
describe("LocationSearch", () => { 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", () => it.live("searches files in the active Location with structured bounded results", () =>
withTmp((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {

View File

@ -3,6 +3,8 @@ import { Tool, ToolFailure } from "@opencode-ai/llm"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { Effect, Exit, Layer, Schema, Scope } from "effect" import { Effect, Exit, Layer, Schema, Scope } from "effect"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
@ -24,7 +26,15 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"), 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 it = testEffect(Layer.mergeAll(permission, registry))
const echo = Tool.make({ const echo = Tool.make({
@ -180,6 +190,7 @@ describe("ToolRegistry", () => {
it.effect("settles encoded structured output with canonical projected content", () => it.effect("settles encoded structured output with canonical projected content", () =>
Effect.gen(function* () { Effect.gen(function* () {
bounds.length = 0
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform() const transform = yield* registry.transform()
@ -202,10 +213,17 @@ describe("ToolRegistry", () => {
sessionID: SessionV2.ID.make("ses_registry_test"), sessionID: SessionV2.ID.make("ses_registry_test"),
call: { type: "tool-call", id: "call-projected", name: "projected", input: { prefix: "count" } }, call: { type: "tool-call", id: "call-projected", name: "projected", input: { prefix: "count" } },
}), }),
).toEqual({ ).toMatchObject({
result: { type: "text", value: "call-projected:count:2" }, result: { type: "text", value: "call-projected:count:2" },
output: { structured: { count: "2" }, content: [{ type: "text", text: "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 * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
@ -117,7 +118,11 @@ const permission = Layer.succeed(
}), }),
) )
const applications = ApplicationTools.layer 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 agents = AgentV2.layer
const echo = Layer.effectDiscard( const echo = Layer.effectDiscard(
ToolRegistry.Service.use((registry) => ToolRegistry.Service.use((registry) =>

View File

@ -74,7 +74,7 @@ const resources = Layer.succeed(
limits: () => Effect.die("unused"), limits: () => Effect.die("unused"),
write: () => Effect.die("unused"), write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))), 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"), 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.acquireUseRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(tmp) => { (tmp) => {
@ -303,13 +303,9 @@ describe("BashTool", () => {
result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") } result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") }
truncate = (input) => truncate = (input) =>
Effect.succeed({ 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, truncated: true,
resource: new ToolOutputStore.Resource({ outputPath: "/tmp/tool-output/tool_opaque",
uri: "tool-output://opaque",
mime: "text/plain",
size: input.content.length,
}),
}) })
return withTool(tmp.path, (registry) => registry.settle(call({ command: "false" }, "call-overflow"))).pipe( return withTool(tmp.path, (registry) => registry.settle(call({ command: "false" }, "call-overflow"))).pipe(
Effect.andThen((settled) => Effect.andThen((settled) =>
@ -323,12 +319,12 @@ describe("BashTool", () => {
cwd: realpathSync(tmp.path), cwd: realpathSync(tmp.path),
exitCode: 7, exitCode: 7,
truncated: true, 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([ expect(truncations).toMatchObject([
{ sessionID, toolCallID: "call-overflow", content: "HEAD full output TAIL" }, { 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" import { tmpdir } from "./fixture/tmpdir"
const sessionID = SessionV2.ID.make("ses_tool_output_store") const sessionID = SessionV2.ID.make("ses_tool_output_store")
const otherSessionID = SessionV2.ID.make("ses_tool_output_store_other")
const withStore = <A, E, R>( const withStore = <A, E, R>(
body: (input: { root: string; store: ToolOutputStore.Interface; fs: FSUtil.Interface }) => Effect.Effect<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) const it = testEffect(Layer.empty)
describe("ToolOutputStore", () => { 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 }) => withStore(({ store }) =>
Effect.gen(function* () { Effect.gen(function* () {
expect(yield* store.truncate({ sessionID, toolCallID: "call-short", content: "line one\nline two" })).toEqual({ expect(yield* store.truncate({ sessionID, toolCallID: "call-short", content: "one\ntwo" })).toEqual({
content: "line one\nline two", content: "one\ntwo",
truncated: false, truncated: false,
}) })
}), }),
), ),
) )
it.live("stores byte-truncated output and returns an opaque head-tail preview", () => it.live("stores full output at an absolute managed path", () =>
withStore(({ store }) => withStore(({ root, store, fs }) =>
Effect.gen(function* () { Effect.gen(function* () {
const content = "HEAD-" + "x".repeat(100) + "-TAIL" const content = "HEAD-" + "x".repeat(500) + "-TAIL"
const result = yield* store.truncate({ sessionID, toolCallID: "call-bytes", content, maxBytes: 20 }) const result = yield* store.truncate({ sessionID, toolCallID: "call-large", content, maxBytes: 300 })
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
if (!result.truncated) throw new Error("expected truncation") 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("HEAD-")
expect(result.content).toContain("-TAIL") expect(result.content).toContain("-TAIL")
expect(result.content).toContain("output truncated") expect(yield* fs.readFileString(result.outputPath)).toBe(content)
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)
}), }),
), ),
) )
it.live("stores line-truncated output and keeps both ends in the preview", () => it.live("bounds aggregate text blocks with one managed file", () =>
withStore(({ store }) => withStore(({ store, fs }) =>
Effect.gen(function* () { Effect.gen(function* () {
const content = Array.from({ length: 10 }, (_, index) => `line-${index}`).join("\n") const first = "HEAD-" + "x".repeat(30_000)
const result = yield* store.truncate({ sessionID, toolCallID: "call-lines", content, maxLines: 4 }) const second = "y".repeat(30_000) + "-TAIL"
const result = yield* store.bound({
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({
sessionID, sessionID,
toolCallID: "call-one-line", toolCallID: "call-aggregate",
content: "one\ntwo\nthree", output: {
maxLines: 1, structured: { kind: "report" },
content: [
{ type: "text", text: first },
{ type: "text", text: second },
],
},
}) })
expect(result.output.structured).toEqual({ kind: "report" })
expect(result.truncated).toBe(true) expect(result.outputPaths).toHaveLength(1)
if (!result.truncated) throw new Error("expected truncation") expect(yield* fs.readFileString(result.outputPaths[0]!)).toBe(`${first}\n\n${second}`)
const preview = result.content.split("\n\n... output truncated")[0] if (result.output.content[0]?.type !== "text") throw new Error("expected text preview")
expect(preview).toBe("one") 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 }) => withStore(({ root, store, fs }) =>
Effect.gen(function* () { 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, sessionID,
toolCallID: "call-page", toolCallID: "call-lossy",
content: "0123456789", output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
name: "out.txt",
}) })
const first = yield* store.read({ sessionID, uri: resource.uri, limit: 4 }) expect(result.outputPaths).toEqual([])
const second = yield* store.read({ sessionID, uri: resource.uri, offset: first.next, limit: 4 }) if (result.output.content[0]?.type !== "text") throw new Error("expected text preview")
const last = yield* store.read({ sessionID, uri: resource.uri, offset: second.next, limit: 4 }) expect(result.output.content[0].text).toContain("could not be retained")
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 })
}), }),
), ),
) )
it.live("allows the owning session and denies cross-session reads", () => it.live("honors configured limits", () =>
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", () =>
withStore( withStore(
({ store }) => ({ store }) =>
Effect.gen(function* () { 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 }) => withStore(({ root, store, fs }) =>
Effect.gen(function* () { Effect.gen(function* () {
const old = yield* store.write({ sessionID, toolCallID: "call-old", content: "old" }) const old = yield* store.write({ sessionID, toolCallID: "old", content: "old" })
const recent = yield* store.write({ sessionID, toolCallID: "call-recent", content: "recent" }) const recent = yield* store.write({ sessionID, toolCallID: "recent", content: "recent" })
const directory = path.join(root, "tool-output", "managed") const unrelated = path.join(root, "tool-output", "keep.txt")
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 }),
)
yield* fs.writeFileString(unrelated, "keep") 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() yield* store.cleanup()
expect(yield* fs.exists(old)).toBe(false)
expect(yield* fs.exists(path.join(directory, `${oldID}.txt`))).toBe(false) expect(yield* fs.exists(recent)).toBe(true)
expect(yield* fs.exists(oldMetadata)).toBe(false)
expect(yield* fs.exists(path.join(directory, `${recentID}.txt`))).toBe(true)
expect(yield* fs.exists(unrelated)).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 { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ReadTool } from "@opencode-ai/core/tool/read" 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 { RelativePath } from "@opencode-ai/core/schema"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
@ -21,7 +20,6 @@ let listReal = "/project/src"
let size = 5 let size = 5
let real = "/project/README.md" let real = "/project/README.md"
let afterApproval = () => {} let afterApproval = () => {}
const resourceReads: ToolOutputStore.ReadInput[] = []
const filesystem = Layer.succeed( const filesystem = Layer.succeed(
FileSystem.Service, FileSystem.Service,
FileSystem.Service.of({ FileSystem.Service.of({
@ -128,32 +126,8 @@ const permission = Layer.succeed(
}), }),
) )
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const resources = Layer.succeed( const read = ReadTool.layer.pipe(Layer.provide(registry), Layer.provide(filesystem), Layer.provide(permission))
ToolOutputStore.Service, const it = testEffect(Layer.mergeAll(registry, filesystem, permission, read))
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 sessionID = SessionV2.ID.make("ses_read_tool_test") const sessionID = SessionV2.ID.make("ses_read_tool_test")
describe("ReadTool", () => { 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", () => it.effect("lists a bounded directory page through read", () =>
Effect.gen(function* () { Effect.gen(function* () {
assertions.length = 0 assertions.length = 0

View File

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

View File

@ -44,7 +44,7 @@ const resources = Layer.succeed(
limits: () => Effect.die("unused"), limits: () => Effect.die("unused"),
write: () => Effect.die("unused"), write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))), 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"), 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* () { Effect.gen(function* () {
reset() reset()
truncate = (input) => truncate = (input) =>
Effect.succeed({ 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, truncated: true,
resource: new ToolOutputStore.Resource({ outputPath: "/tmp/tool-output/tool_opaque",
uri: "tool-output://opaque",
mime: input.mime ?? "text/plain",
size: input.content.length,
}),
}) })
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const settled = yield* registry.settle(call({ url: "https://1.1.1.1", format: "html" }, "call-overflow")) 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({ expect(settled.output?.structured).toMatchObject({
truncated: true, 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" }]) 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"), limits: () => Effect.die("unused"),
write: () => Effect.die("unused"), write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))), 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"), cleanup: () => Effect.die("unused"),
}), }),
) )
@ -287,13 +287,9 @@ describe("WebSearchTool contribution", () => {
config = { provider: "exa", enableExa: false, enableParallel: false } config = { provider: "exa", enableExa: false, enableParallel: false }
truncate = (input) => truncate = (input) =>
Effect.succeed({ 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, truncated: true,
resource: new ToolOutputStore.Resource({ outputPath: "/tmp/tool-output/tool_opaque",
uri: "tool-output://opaque",
mime: "text/plain",
size: input.content.length,
}),
}) })
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
@ -302,11 +298,14 @@ describe("WebSearchTool contribution", () => {
call: { type: "tool-call", id: "call-overflow", name: "websearch", input: { query: "verbose" } }, 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({ expect(settled.output?.structured).toMatchObject({
provider: "exa", provider: "exa",
truncated: true, 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" }]) expect(truncations).toEqual([{ sessionID, toolCallID: "call-overflow", content: "full search results" }])
}), }),

View File

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

View File

@ -178,28 +178,26 @@ Compatibility:
- Tool results are durably settled before provider continuation. - 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. - 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: Affected schema:
- New `ToolOutputStore.Resource` and `ToolOutputStore.Page` schemas. - New optional managed `outputPath` and `outputPaths` fields on tool results and completed Session tool state.
- New `tool-output://<opaque-id>` URI contract. - Absolute managed output paths accepted by ordinary `read` and `grep` inputs.
- `read` tool resource-page input.
Change: Change:
- Spill oversized model-facing tool text into Session-owned opaque managed resources. - Spill oversized model-facing tool text into globally unique files under OpenCode's shared tool-output directory.
- Page stored UTF-8 content by byte offset with bounded reads and explicit `truncated` and `next` metadata. - Include the absolute file path in the bounded preview so ordinary `read`, `grep`, and `bash` operations can inspect it.
Reason: Reason:
- Tool results need bounded model context without discarding the full output. - 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: Compatibility:
- This is an additive internal and model-facing resource contract. - Managed output is retained for a bounded period and exposed as a normal host filesystem path.
- Managed output is retained for a bounded period and is not a public filesystem path.
### Location-Scoped Filesystem Read And Search Contracts ### 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. - 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. - 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. - 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 ## Remove Dedicated `session.init` Route