feat(core): bound v2 tool output (#30999)
This commit is contained in:
parent
760d523847
commit
a9094fd059
31 changed files with 387 additions and 552 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) })],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)))))
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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* () {
|
||||
|
|
|
|||
|
|
@ -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* () {
|
||||
|
|
|
|||
|
|
@ -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" }] },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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" }])
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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" }])
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue