mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 10:16:03 +00:00
fix(core): bound tool structured output
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
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, Predicate, Schedule, Schema } from "effect"
|
||||
import { Config } from "./config"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
@@ -12,6 +12,7 @@ import type { ToolOutput } from "@opencode-ai/ai"
|
||||
|
||||
export const MAX_LINES = 2_000
|
||||
export const MAX_BYTES = 50 * 1024
|
||||
export const MAX_STRUCTURED_BYTES = 16 * 1024
|
||||
export const RETENTION = Duration.days(7)
|
||||
|
||||
export const MANAGED_DIRECTORY = "tool-output"
|
||||
@@ -49,26 +50,31 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
|
||||
const takePrefix = (input: string, maximumBytes: number) => {
|
||||
let bytes = 0
|
||||
let content = ""
|
||||
let end = 0
|
||||
for (const char of input) {
|
||||
const size = Buffer.byteLength(char, "utf-8")
|
||||
if (bytes + size > maximumBytes) break
|
||||
content += char
|
||||
end += char.length
|
||||
bytes += size
|
||||
}
|
||||
return content
|
||||
return input.slice(0, end)
|
||||
}
|
||||
|
||||
const takeSuffix = (input: string, maximumBytes: number) => {
|
||||
let bytes = 0
|
||||
const content: string[] = []
|
||||
for (const char of Array.from(input).toReversed()) {
|
||||
let start = input.length
|
||||
while (start > 0) {
|
||||
const codeUnit = input.charCodeAt(start - 1)
|
||||
const previous = start > 1 ? input.charCodeAt(start - 2) : 0
|
||||
const next =
|
||||
codeUnit >= 0xdc00 && codeUnit <= 0xdfff && previous >= 0xd800 && previous <= 0xdbff ? start - 2 : start - 1
|
||||
const char = input.slice(next, start)
|
||||
const size = Buffer.byteLength(char, "utf-8")
|
||||
if (bytes + size > maximumBytes) break
|
||||
content.unshift(char)
|
||||
start = next
|
||||
bytes += size
|
||||
}
|
||||
return content.join("")
|
||||
return input.slice(start)
|
||||
}
|
||||
|
||||
const preview = (text: string, maxLines: number, maxBytes: number) => {
|
||||
@@ -128,7 +134,6 @@ const layer = Layer.effect(
|
||||
|
||||
const write = Effect.fn("ToolOutputStore.write")(function* (content: string) {
|
||||
const file = path.join(directory, `tool_${Identifier.ascending()}`)
|
||||
yield* fs.ensureDir(directory).pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
|
||||
yield* fs
|
||||
.writeFileString(file, content, { flag: "wx" })
|
||||
.pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
|
||||
@@ -139,37 +144,58 @@ const layer = Layer.effect(
|
||||
const outputLimits = yield* limits()
|
||||
const media = input.output.content.filter((item) => item.type === "file")
|
||||
const text = input.output.content.filter((item) => item.type === "text")
|
||||
const contextual =
|
||||
input.output.content.length === 0
|
||||
? yield* Effect.try({
|
||||
try: () => JSON.stringify(input.output.structured, null, 2) ?? String(input.output.structured),
|
||||
catch: (cause) => new StorageError({ operation: "encode", cause }),
|
||||
})
|
||||
: text.map((item) => item.text).join("")
|
||||
if (
|
||||
lineCount(contextual) <= outputLimits.maxLines &&
|
||||
Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes
|
||||
)
|
||||
const encoded = yield* Effect.try({
|
||||
try: () => JSON.stringify(input.output.structured) ?? String(input.output.structured),
|
||||
catch: (cause) => new StorageError({ operation: "encode", cause }),
|
||||
})
|
||||
const encodedBytes = Buffer.byteLength(encoded, "utf-8")
|
||||
const contextual = input.output.content.length === 0 ? encoded : text.map((item) => item.text).join("")
|
||||
const contextualBytes = input.output.content.length === 0 ? encodedBytes : Buffer.byteLength(contextual, "utf-8")
|
||||
const structuredOverflow = encodedBytes > MAX_STRUCTURED_BYTES
|
||||
const contextualOverflow =
|
||||
contextualBytes > outputLimits.maxBytes || lineCount(contextual) > outputLimits.maxLines
|
||||
if (!structuredOverflow && !contextualOverflow)
|
||||
return {
|
||||
output: input.output,
|
||||
outputPaths: [],
|
||||
}
|
||||
|
||||
const outputPath = yield* write(contextual)
|
||||
const marker = `... output truncated; full content saved to ${outputPath} ...`
|
||||
yield* fs.ensureDir(directory).pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
|
||||
const structuredPath = structuredOverflow ? yield* write(encoded) : undefined
|
||||
const contextualPath = contextualOverflow
|
||||
? input.output.content.length === 0 && structuredPath
|
||||
? structuredPath
|
||||
: yield* write(contextual)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
output: {
|
||||
structured: input.output.structured,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
|
||||
},
|
||||
...media,
|
||||
],
|
||||
structured: structuredPath
|
||||
? { _truncated: true, _bytes: encodedBytes, _outputPath: structuredPath }
|
||||
: contextualOverflow &&
|
||||
Predicate.isObject(input.output.structured) &&
|
||||
"truncated" in input.output.structured &&
|
||||
typeof input.output.structured.truncated === "boolean"
|
||||
? { ...input.output.structured, truncated: true }
|
||||
: input.output.structured,
|
||||
content: contextualOverflow
|
||||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: boundedPreview(
|
||||
contextual,
|
||||
`... output truncated; full content saved to ${contextualPath} ...`,
|
||||
outputLimits.maxLines,
|
||||
outputLimits.maxBytes,
|
||||
),
|
||||
},
|
||||
...media,
|
||||
]
|
||||
: input.output.content.length === 0 && structuredPath
|
||||
? [{ type: "text" as const, text: contextual }]
|
||||
: input.output.content,
|
||||
},
|
||||
outputPaths: [outputPath],
|
||||
outputPaths: [...new Set([structuredPath, contextualPath].filter((value) => value !== undefined))],
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ export * as EditTool from "./edit"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { diffLines } from "diff"
|
||||
import { Effect, Schema, Struct } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
@@ -31,8 +31,10 @@ export const Input = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
|
||||
const FileInfo = FileDiff.Info.mapFields(Struct.omit(["patch"]))
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
files: Schema.Array(FileDiff.Info),
|
||||
files: Schema.Array(FileInfo),
|
||||
replacements: Schema.Number,
|
||||
})
|
||||
export type Output = typeof Output.Type
|
||||
@@ -200,7 +202,6 @@ export const Plugin = {
|
||||
files: [
|
||||
{
|
||||
file: result.resource,
|
||||
patch: createTwoFilesPatch(result.resource, result.resource, source.text, replaced),
|
||||
status: "modified" as const,
|
||||
...counts,
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ export const Input = Schema.Struct({
|
||||
|
||||
export const Output = Schema.Array(FileSystem.Entry)
|
||||
type ModelOutput = typeof Output.Encoded
|
||||
const StructuredOutput = Schema.Struct({ count: Schema.Number })
|
||||
|
||||
/** Format raw search results into the concise line-oriented output models expect. */
|
||||
export const toModelOutput = (output: ModelOutput) => {
|
||||
@@ -51,6 +52,8 @@ export const Plugin = {
|
||||
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
toStructuredOutput: ({ output }) => ({ count: output.length }),
|
||||
toModelOutput: ({ output }) => [
|
||||
{
|
||||
type: "text",
|
||||
|
||||
@@ -31,6 +31,7 @@ export const Input = Schema.Struct({
|
||||
|
||||
export const Output = Schema.Array(FileSystem.Match)
|
||||
type ModelOutput = typeof Output.Encoded
|
||||
const StructuredOutput = Schema.Struct({ matches: Schema.Number })
|
||||
|
||||
/** Format raw search matches into the familiar concise model output. */
|
||||
export const toModelOutput = (output: ModelOutput) => {
|
||||
@@ -65,6 +66,8 @@ export const Plugin = {
|
||||
"Search file contents by regular expression within the active Location 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.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
toStructuredOutput: ({ output }) => ({ matches: output.length }),
|
||||
toModelOutput: ({ output }) => [
|
||||
{
|
||||
type: "text",
|
||||
|
||||
@@ -3,8 +3,8 @@ export * as PatchTool from "./patch"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { diffLines } from "diff"
|
||||
import { Effect, Schema, Struct } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
@@ -26,9 +26,11 @@ export const Applied = Schema.Struct({
|
||||
target: Schema.String,
|
||||
})
|
||||
|
||||
const FileInfo = FileDiff.Info.mapFields(Struct.omit(["patch"]))
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
applied: Schema.Array(Applied),
|
||||
files: Schema.Array(FileDiff.Info),
|
||||
files: Schema.Array(FileInfo),
|
||||
})
|
||||
export type Output = typeof Output.Type
|
||||
|
||||
@@ -211,7 +213,7 @@ export const Plugin = {
|
||||
}),
|
||||
}
|
||||
|
||||
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
|
||||
function patchFile(change: Prepared): typeof FileInfo.Type {
|
||||
const counts = diffLines(change.before, change.after).reduce(
|
||||
(result, item) => ({
|
||||
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
|
||||
@@ -221,7 +223,6 @@ function patchFile(change: Prepared): typeof FileDiff.Info.Type {
|
||||
)
|
||||
return {
|
||||
file: change.target.resource,
|
||||
patch: createTwoFilesPatch(change.target.resource, change.target.resource, change.before, change.after),
|
||||
status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified",
|
||||
...counts,
|
||||
}
|
||||
|
||||
@@ -28,6 +28,31 @@ const LocationInput = Schema.Struct({
|
||||
})
|
||||
const Input = LocationInput
|
||||
const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
|
||||
const StructuredOutput = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("file"),
|
||||
uri: Schema.String,
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
encoding: FileSystem.Content.fields.encoding,
|
||||
mime: Schema.String,
|
||||
bytes: Schema.Number,
|
||||
truncated: Schema.Boolean,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("text-page"),
|
||||
mime: Schema.String,
|
||||
offset: Schema.Number,
|
||||
bytes: Schema.Number,
|
||||
truncated: Schema.Boolean,
|
||||
next: Schema.Number.pipe(Schema.optional),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("list-page"),
|
||||
count: Schema.Number,
|
||||
truncated: Schema.Boolean,
|
||||
next: Schema.Number.pipe(Schema.optional),
|
||||
}),
|
||||
])
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.read",
|
||||
@@ -48,15 +73,37 @@ export const Plugin = {
|
||||
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: Schema.toEncoded(Output),
|
||||
// Image base64 reaches the model through content items (normalized generically
|
||||
// at tool settlement); persisting a second copy in structured would store the
|
||||
// original unresized bytes in the message row.
|
||||
toStructuredOutput: ({ output }) =>
|
||||
"encoding" in output && output.encoding === "base64" ? { ...output, content: "" } : output,
|
||||
structured: StructuredOutput,
|
||||
toStructuredOutput: ({ output }) => {
|
||||
if ("encoding" in output)
|
||||
return {
|
||||
type: "file" as const,
|
||||
uri: output.uri,
|
||||
name: output.name,
|
||||
encoding: output.encoding,
|
||||
mime: output.mime,
|
||||
bytes: Buffer.byteLength(output.content, output.encoding === "base64" ? "base64" : "utf-8"),
|
||||
truncated: false,
|
||||
}
|
||||
if ("content" in output)
|
||||
return {
|
||||
type: output.type,
|
||||
mime: output.mime,
|
||||
offset: output.offset,
|
||||
bytes: Buffer.byteLength(output.content, "utf-8"),
|
||||
truncated: output.truncated,
|
||||
next: output.next,
|
||||
}
|
||||
return {
|
||||
type: "list-page" as const,
|
||||
count: output.entries.length,
|
||||
truncated: output.truncated,
|
||||
next: output.next,
|
||||
}
|
||||
},
|
||||
toModelOutput: ({ input, output }) => {
|
||||
if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime))
|
||||
return []
|
||||
return [{ type: "text", text: JSON.stringify(output) }]
|
||||
return [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", data: output.content, mime: output.mime, name: input.path },
|
||||
|
||||
@@ -22,6 +22,13 @@ export const Output = Schema.Struct({
|
||||
output: Schema.String,
|
||||
})
|
||||
|
||||
const StructuredOutput = Schema.Struct({
|
||||
name: Schema.String,
|
||||
directory: Schema.String,
|
||||
bytes: Schema.Number,
|
||||
truncated: Schema.Boolean,
|
||||
})
|
||||
|
||||
export const description = [
|
||||
"Load a specialized skill when the task at hand matches one of the available skills in the instructions.",
|
||||
"",
|
||||
@@ -66,6 +73,13 @@ export const Plugin = {
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
toStructuredOutput: ({ output }) => ({
|
||||
name: output.name,
|
||||
directory: output.directory,
|
||||
bytes: Buffer.byteLength(output.output, "utf-8"),
|
||||
truncated: false,
|
||||
}),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -38,6 +38,14 @@ const Output = Schema.Struct({
|
||||
output: Schema.String,
|
||||
})
|
||||
|
||||
const StructuredOutput = Schema.Struct({
|
||||
url: Schema.String,
|
||||
contentType: Schema.String,
|
||||
format: Input.fields.format,
|
||||
bytes: Schema.Number,
|
||||
truncated: Schema.Boolean,
|
||||
})
|
||||
|
||||
type Format = (typeof Input.Type)["format"]
|
||||
|
||||
const acceptHeader = (format: Format) => {
|
||||
@@ -126,6 +134,14 @@ export const Plugin = {
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
toStructuredOutput: ({ input, output }) => ({
|
||||
url: output.url,
|
||||
contentType: output.contentType,
|
||||
format: input.format,
|
||||
bytes: Buffer.byteLength(output.output, "utf-8"),
|
||||
truncated: false,
|
||||
}),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -187,6 +187,12 @@ const Output = Schema.Struct({
|
||||
text: Schema.String,
|
||||
})
|
||||
|
||||
const StructuredOutput = Schema.Struct({
|
||||
provider: Provider,
|
||||
bytes: Schema.Number,
|
||||
truncated: Schema.Boolean,
|
||||
})
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.websearch",
|
||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
@@ -202,6 +208,12 @@ export const Plugin = {
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
toStructuredOutput: ({ output }) => ({
|
||||
provider: output.provider,
|
||||
bytes: Buffer.byteLength(output.text, "utf-8"),
|
||||
truncated: false,
|
||||
}),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
||||
execute: (input, context) => {
|
||||
const provider = selectProvider(context.sessionID, config, config.provider)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import type { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
@@ -11,6 +12,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const bounds: ToolOutputStore.BoundInput[] = []
|
||||
const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
|
||||
@@ -52,6 +54,7 @@ const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [
|
||||
[Image.node, imageStore],
|
||||
])
|
||||
const it = testEffect(registryLayer)
|
||||
const live = testEffect(Layer.empty)
|
||||
const identity = {
|
||||
agent: AgentV2.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_registry"),
|
||||
@@ -84,6 +87,52 @@ const constant = (text: string) =>
|
||||
})
|
||||
|
||||
describe("ToolRegistry", () => {
|
||||
live.live("bounds structured-only output before deriving the model result", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
const layer = AppNodeBuilder.build(ToolRegistry.node, [
|
||||
[Global.node, Global.layerWith({ data: tmp.path })],
|
||||
[Image.node, imageStore],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
])
|
||||
return Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const text = "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES)
|
||||
yield* service.register(
|
||||
{
|
||||
structured_only: Tool.make({
|
||||
description: "Return structured output",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: () => Effect.succeed({ text }),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
const settled = yield* settleTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "call-structured-only", name: "structured_only", input: {} },
|
||||
})
|
||||
const encoded = JSON.stringify({ text })
|
||||
|
||||
expect(settled.result).toEqual({ type: "text", value: encoded })
|
||||
expect(settled.outputPaths).toHaveLength(1)
|
||||
expect(settled.output).toEqual({
|
||||
structured: {
|
||||
_truncated: true,
|
||||
_bytes: Buffer.byteLength(encoded),
|
||||
_outputPath: settled.outputPaths?.[0],
|
||||
},
|
||||
content: [{ type: "text", text: encoded }],
|
||||
})
|
||||
}).pipe(Effect.provide(layer))
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects invalid dotted namespaces", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
@@ -113,12 +162,15 @@ describe("ToolRegistry", () => {
|
||||
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({
|
||||
question: make(),
|
||||
bash: make(),
|
||||
edit: make("edit"),
|
||||
write: make("edit"),
|
||||
}, { codemode: false })
|
||||
yield* service.register(
|
||||
{
|
||||
question: make(),
|
||||
bash: make(),
|
||||
edit: make("edit"),
|
||||
write: make("edit"),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
const names = (permissions: PermissionV2.Ruleset) =>
|
||||
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
|
||||
|
||||
@@ -191,14 +243,17 @@ describe("ToolRegistry", () => {
|
||||
it.effect("returns model errors without swallowing interruption or defects", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({
|
||||
failed: Tool.make({
|
||||
description: "Failed",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
|
||||
}),
|
||||
}, { codemode: false })
|
||||
yield* service.register(
|
||||
{
|
||||
failed: Tool.make({
|
||||
description: "Failed",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
@@ -214,14 +269,17 @@ describe("ToolRegistry", () => {
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unknown tool: missing" })
|
||||
|
||||
yield* service.register({
|
||||
defect: Tool.make({
|
||||
description: "Defect",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die("unexpected executor defect"),
|
||||
}),
|
||||
}, { codemode: false })
|
||||
yield* service.register(
|
||||
{
|
||||
defect: Tool.make({
|
||||
description: "Defect",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die("unexpected executor defect"),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
expect(
|
||||
yield* service.materialize().pipe(
|
||||
Effect.flatMap((materialized) =>
|
||||
@@ -264,22 +322,23 @@ describe("ToolRegistry", () => {
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const contexts: Tool.Context[] = []
|
||||
yield* service.register({
|
||||
context: Tool.make({
|
||||
description: "Context",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
|
||||
}),
|
||||
}, { codemode: false })
|
||||
yield* service.register(
|
||||
{
|
||||
context: Tool.make({
|
||||
description: "Context",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
|
||||
})
|
||||
expect(contexts).toEqual([
|
||||
{ sessionID, ...identity, callID: "call-context", progress: expect.any(Function) },
|
||||
])
|
||||
expect(contexts).toEqual([{ sessionID, ...identity, callID: "call-context", progress: expect.any(Function) }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -306,20 +365,23 @@ describe("ToolRegistry", () => {
|
||||
it.effect("normalizes image tool output at settlement and drops unresizable images", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({
|
||||
snapshot: Tool.make({
|
||||
description: "Return images",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.succeed({ text }),
|
||||
toModelOutput: ({ output }) => [
|
||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
|
||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
|
||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" },
|
||||
{ type: "text", text: output.text },
|
||||
],
|
||||
}),
|
||||
}, { codemode: false })
|
||||
yield* service.register(
|
||||
{
|
||||
snapshot: Tool.make({
|
||||
description: "Return images",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.succeed({ text }),
|
||||
toModelOutput: ({ output }) => [
|
||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
|
||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
|
||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" },
|
||||
{ type: "text", text: output.text },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
|
||||
const settlement = yield* settleTool(service, call("snapshot"))
|
||||
expect(settlement.output?.content).toEqual([
|
||||
@@ -334,23 +396,26 @@ describe("ToolRegistry", () => {
|
||||
it.effect("normalizes image progress content before it is published", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({
|
||||
progressive: Tool.make({
|
||||
description: "Emit image progress",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }, context) =>
|
||||
context
|
||||
.progress({
|
||||
structured: { stage: "capture" },
|
||||
content: [
|
||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
|
||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
|
||||
],
|
||||
})
|
||||
.pipe(Effect.as({ text })),
|
||||
}),
|
||||
}, { codemode: false })
|
||||
yield* service.register(
|
||||
{
|
||||
progressive: Tool.make({
|
||||
description: "Emit image progress",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }, context) =>
|
||||
context
|
||||
.progress({
|
||||
structured: { stage: "capture" },
|
||||
content: [
|
||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
|
||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
|
||||
],
|
||||
})
|
||||
.pipe(Effect.as({ text })),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
|
||||
const updates: ToolRegistry.Progress[] = []
|
||||
yield* settleTool(service, {
|
||||
@@ -382,15 +447,18 @@ describe("ToolRegistry", () => {
|
||||
encode: SchemaGetter.transform((value) => value === "yes"),
|
||||
}),
|
||||
)
|
||||
yield* service.register({
|
||||
transformed: Tool.make({
|
||||
description: "Transform values",
|
||||
input: Schema.Struct({ value: Transformed }),
|
||||
output: Schema.Struct({ value: Transformed }),
|
||||
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
|
||||
}),
|
||||
}, { codemode: false })
|
||||
yield* service.register(
|
||||
{
|
||||
transformed: Tool.make({
|
||||
description: "Transform values",
|
||||
input: Schema.Struct({ value: Transformed }),
|
||||
output: Schema.Struct({ value: Transformed }),
|
||||
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
@@ -409,25 +477,28 @@ describe("ToolRegistry", () => {
|
||||
).toMatchObject({ type: "error", value: expect.stringContaining("Invalid tool input") })
|
||||
expect(executed).toEqual(["yes"])
|
||||
|
||||
yield* service.register({
|
||||
invalid_output: Tool.make({
|
||||
description: "Return invalid output",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({
|
||||
value: Schema.Boolean.pipe(
|
||||
Schema.decodeTo(Schema.String, {
|
||||
decode: SchemaGetter.transform((value) => String(value)),
|
||||
encode: SchemaGetter.transformOrFail((value) =>
|
||||
value === "valid"
|
||||
? Effect.succeed(true)
|
||||
: Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
yield* service.register(
|
||||
{
|
||||
invalid_output: Tool.make({
|
||||
description: "Return invalid output",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({
|
||||
value: Schema.Boolean.pipe(
|
||||
Schema.decodeTo(Schema.String, {
|
||||
decode: SchemaGetter.transform((value) => String(value)),
|
||||
encode: SchemaGetter.transformOrFail((value) =>
|
||||
value === "valid"
|
||||
? Effect.succeed(true)
|
||||
: Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
execute: () => Effect.succeed({ value: "invalid" }),
|
||||
}),
|
||||
execute: () => Effect.succeed({ value: "invalid" }),
|
||||
}),
|
||||
}, { codemode: false })
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
|
||||
@@ -158,10 +158,10 @@ describe("EditTool", () => {
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: expect.stringContaining("-before\n+after"),
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(settled.output?.structured).not.toHaveProperty("files.0.patch")
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
|
||||
expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))])
|
||||
@@ -366,6 +366,7 @@ describe("EditTool", () => {
|
||||
Effect.andThen((settled) =>
|
||||
Effect.gen(function* () {
|
||||
expect(settled.output?.structured).toMatchObject({ replacements: 3 })
|
||||
expect(settled.output?.structured).not.toHaveProperty("files.0.patch")
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after after after")
|
||||
expect(writes).toHaveLength(1)
|
||||
}),
|
||||
|
||||
@@ -75,7 +75,11 @@ describe("ToolOutputStore", () => {
|
||||
Effect.gen(function* () {
|
||||
const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) }
|
||||
const result = yield* store.bound({ sessionID, callID: "call-json", output: { structured, content: [] } })
|
||||
expect(result.output.structured).toEqual(structured)
|
||||
expect(result.output.structured).toEqual({
|
||||
_truncated: true,
|
||||
_bytes: Buffer.byteLength(JSON.stringify(structured)),
|
||||
_outputPath: result.outputPaths[0],
|
||||
})
|
||||
expect(result.outputPaths).toHaveLength(1)
|
||||
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
|
||||
expect(result.output.content).toHaveLength(1)
|
||||
@@ -83,6 +87,44 @@ describe("ToolOutputStore", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("projects guarded structured-only output into the bounded content channel", () =>
|
||||
withStore(({ store, fs }) =>
|
||||
Effect.gen(function* () {
|
||||
const structured = { text: "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES) }
|
||||
const result = yield* store.bound({
|
||||
sessionID,
|
||||
callID: "call-structured-content",
|
||||
output: { structured, content: [] },
|
||||
})
|
||||
expect(result.output.structured).toEqual({
|
||||
_truncated: true,
|
||||
_bytes: Buffer.byteLength(JSON.stringify(structured)),
|
||||
_outputPath: result.outputPaths[0],
|
||||
})
|
||||
expect(result.output.content).toEqual([{ type: "text", text: JSON.stringify(structured) }])
|
||||
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps structured output at the receipt boundary inline", () =>
|
||||
withStore(({ store }) =>
|
||||
Effect.gen(function* () {
|
||||
const empty = JSON.stringify({ text: "" })
|
||||
const structured = { text: "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES - Buffer.byteLength(empty)) }
|
||||
expect(Buffer.byteLength(JSON.stringify(structured))).toBe(ToolOutputStore.MAX_STRUCTURED_BYTES)
|
||||
|
||||
const result = yield* store.bound({
|
||||
sessionID,
|
||||
callID: "call-structured-boundary",
|
||||
output: { structured, content: [] },
|
||||
})
|
||||
|
||||
expect(result).toEqual({ output: { structured, content: [] }, outputPaths: [] })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves native media and structured metadata without applying a settlement media limit", () =>
|
||||
withStore(({ store }) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -131,15 +173,35 @@ describe("ToolOutputStore", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not double-count structured data duplicated in projected text", () =>
|
||||
it.live("marks receipt metadata truncated when bounding its contextual output", () =>
|
||||
withStore(({ store }) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* store.bound({
|
||||
sessionID,
|
||||
callID: "call-receipt",
|
||||
output: {
|
||||
structured: { bytes: ToolOutputStore.MAX_BYTES + 1, truncated: false },
|
||||
content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }],
|
||||
},
|
||||
})
|
||||
expect(result.output.structured).toEqual({ bytes: ToolOutputStore.MAX_BYTES + 1, truncated: true })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("bounds structured data duplicated in projected text independently", () =>
|
||||
withStore(({ store, fs }) =>
|
||||
Effect.gen(function* () {
|
||||
const text = "x".repeat(30_000)
|
||||
const output = { structured: { output: text }, content: [{ type: "text" as const, text }] }
|
||||
expect(yield* store.bound({ sessionID, callID: "call-duplicated", output })).toEqual({
|
||||
output,
|
||||
outputPaths: [],
|
||||
const result = yield* store.bound({ sessionID, callID: "call-duplicated", output })
|
||||
expect(result.output.content).toEqual(output.content)
|
||||
expect(result.output.structured).toEqual({
|
||||
_truncated: true,
|
||||
_bytes: Buffer.byteLength(JSON.stringify(output.structured)),
|
||||
_outputPath: result.outputPaths[0],
|
||||
})
|
||||
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(output.structured)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -162,14 +224,17 @@ describe("ToolOutputStore", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not encode ignored structured metadata when projected content exists", () =>
|
||||
it.live("rejects unencodable structured metadata even when projected content exists", () =>
|
||||
withStore(({ store }) =>
|
||||
Effect.gen(function* () {
|
||||
const output = { structured: { value: 1n }, content: [{ type: "text" as const, text: "readable text" }] }
|
||||
expect(yield* store.bound({ sessionID, callID: "call-unencodable", output })).toEqual({
|
||||
output,
|
||||
outputPaths: [],
|
||||
})
|
||||
const exit = yield* store.bound({ sessionID, callID: "call-unencodable", output }).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit))
|
||||
expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({
|
||||
_tag: "ToolOutputStore.StorageError",
|
||||
operation: "encode",
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -181,24 +181,24 @@ describe("PatchTool", () => {
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
patch: expect.stringContaining("+created"),
|
||||
},
|
||||
{
|
||||
file: "update.txt",
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: expect.stringContaining("-before\n+after"),
|
||||
},
|
||||
{
|
||||
file: "remove.txt",
|
||||
status: "deleted",
|
||||
additions: 0,
|
||||
deletions: 1,
|
||||
patch: expect.stringContaining("-remove"),
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(settled.output?.structured).not.toHaveProperty("files.0.patch")
|
||||
expect(settled.output?.structured).not.toHaveProperty("files.1.patch")
|
||||
expect(settled.output?.structured).not.toHaveProperty("files.2.patch")
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] },
|
||||
])
|
||||
|
||||
@@ -206,14 +206,14 @@ describe("ReadTool", () => {
|
||||
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
|
||||
}),
|
||||
).toEqual({
|
||||
type: "json",
|
||||
value: {
|
||||
type: "text",
|
||||
value: JSON.stringify({
|
||||
uri: "file:///README.md",
|
||||
name: "README.md",
|
||||
content: "hello",
|
||||
encoding: "utf8",
|
||||
mime: "text/plain",
|
||||
},
|
||||
}),
|
||||
})
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }])
|
||||
expect(readCalls).toEqual([
|
||||
@@ -236,7 +236,7 @@ describe("ReadTool", () => {
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } },
|
||||
}),
|
||||
).toMatchObject({ type: "json" })
|
||||
).toMatchObject({ type: "text" })
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
sessionID,
|
||||
@@ -287,14 +287,15 @@ describe("ReadTool", () => {
|
||||
call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
|
||||
})
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
type: "file",
|
||||
uri: "file:///pixel.png",
|
||||
name: "pixel.png",
|
||||
mime: "image/png",
|
||||
encoding: "base64",
|
||||
// Image base64 is carried by the content file item only; structured is slimmed
|
||||
// so the original bytes are never persisted twice.
|
||||
content: "",
|
||||
bytes: Buffer.byteLength(png, "base64"),
|
||||
truncated: false,
|
||||
})
|
||||
expect(settled.output?.structured).not.toHaveProperty("content")
|
||||
expect(settled.output?.content).toMatchObject([
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
|
||||
@@ -626,7 +627,7 @@ describe("ReadTool", () => {
|
||||
input: { path: "src", offset: 2, limit: 10 },
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: "json", value: { entries: [], truncated: false } })
|
||||
).toEqual({ type: "text", value: JSON.stringify({ entries: [], truncated: false }) })
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
|
||||
expect(listCalls).toEqual([{ offset: 2, limit: 10 }])
|
||||
}),
|
||||
@@ -692,8 +693,15 @@ describe("ReadTool", () => {
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
type: "json",
|
||||
value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
|
||||
type: "text",
|
||||
value: JSON.stringify({
|
||||
type: "text-page",
|
||||
content: "hello",
|
||||
mime: "text/plain",
|
||||
offset: 2,
|
||||
truncated: true,
|
||||
next: 3,
|
||||
}),
|
||||
})
|
||||
expect(readCalls).toEqual([
|
||||
{ input: AbsolutePath.make(path.join(process.cwd(), "large.txt")), page: { offset: 2, limit: 1 } },
|
||||
|
||||
@@ -121,7 +121,14 @@ describe("SkillTool", () => {
|
||||
}),
|
||||
).toMatchObject({
|
||||
result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) },
|
||||
output: { structured: { name: "Effect" } },
|
||||
output: {
|
||||
structured: {
|
||||
name: "Effect",
|
||||
directory,
|
||||
bytes: Buffer.byteLength(SkillTool.toModelOutput(info, [reference])),
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("WebFetchTool registration", () => {
|
||||
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
|
||||
result: { type: "text", value: "hello" },
|
||||
output: {
|
||||
structured: { url, contentType: "text/plain", format: "text", output: "hello" },
|
||||
structured: { url, contentType: "text/plain", format: "text", bytes: 5, truncated: false },
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -244,7 +244,7 @@ describe("WebSearchTool registration", () => {
|
||||
expect(settled).toEqual({
|
||||
result: { type: "text", value: "parallel results" },
|
||||
output: {
|
||||
structured: { provider: "parallel", text: "parallel results" },
|
||||
structured: { provider: "parallel", bytes: 16, truncated: false },
|
||||
content: [{ type: "text", text: "parallel results" }],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user