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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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