fix(core): preserve bounded tool receipts
This commit is contained in:
parent
aa1f91e0d0
commit
f7a72fdf32
22 changed files with 278 additions and 17 deletions
|
|
@ -172,17 +172,16 @@ const layer = Layer.effect(
|
||||||
callID: event.id,
|
callID: event.id,
|
||||||
output: update,
|
output: update,
|
||||||
})
|
})
|
||||||
if (
|
const structured = bounded.output.structured
|
||||||
typeof bounded.output.structured !== "object" ||
|
const entries =
|
||||||
bounded.output.structured === null ||
|
typeof structured === "object" && structured !== null && !Array.isArray(structured)
|
||||||
Array.isArray(bounded.output.structured)
|
? Object.entries(structured)
|
||||||
)
|
: yield* Effect.die(new Error("Tool progress structured output must be an object"))
|
||||||
yield* Effect.die(new Error("Tool progress structured output must be an object"))
|
|
||||||
yield* events.publish(SessionEvent.Tool.Progress, {
|
yield* events.publish(SessionEvent.Tool.Progress, {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
callID: event.id,
|
callID: event.id,
|
||||||
structured: Object.fromEntries(Object.entries(bounded.output.structured)),
|
structured: Object.fromEntries(entries),
|
||||||
content: [...bounded.output.content],
|
content: [...bounded.output.content],
|
||||||
})
|
})
|
||||||
}).pipe(Effect.orDie),
|
}).pipe(Effect.orDie),
|
||||||
|
|
@ -275,6 +274,11 @@ const layer = Layer.effect(
|
||||||
}
|
}
|
||||||
yield* serialized(publisher.failAssistant(error))
|
yield* serialized(publisher.failAssistant(error))
|
||||||
}
|
}
|
||||||
|
if (streamFailure instanceof ToolOutputStore.StorageError) {
|
||||||
|
const error = toSessionError(streamFailure)
|
||||||
|
yield* serialized(publisher.failUnsettledTools(error))
|
||||||
|
yield* serialized(publisher.failAssistant(error))
|
||||||
|
}
|
||||||
// Provider error events only arrive from the stream, so the flag is final here.
|
// Provider error events only arrive from the stream, so the flag is final here.
|
||||||
const providerFailed = publisher.hasProviderError()
|
const providerFailed = publisher.hasProviderError()
|
||||||
|
|
||||||
|
|
@ -301,8 +305,13 @@ const layer = Layer.effect(
|
||||||
// settles so the model may recover. A typed infrastructure failure (tool output
|
// settles so the model may recover. A typed infrastructure failure (tool output
|
||||||
// could not be persisted) also fails the assistant and then fails the drain.
|
// could not be persisted) also fails the assistant and then fails the drain.
|
||||||
const settledFailure = settledCauses.find((cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause))
|
const settledFailure = settledCauses.find((cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause))
|
||||||
const infraError =
|
const typedError =
|
||||||
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
|
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
|
||||||
|
const storageDefect = settledFailure?.reasons.find(
|
||||||
|
(reason) => Cause.isDieReason(reason) && reason.defect instanceof ToolOutputStore.StorageError,
|
||||||
|
)
|
||||||
|
const infraError =
|
||||||
|
typedError ?? (storageDefect && Cause.isDieReason(storageDefect) ? storageDefect.defect : undefined)
|
||||||
if (settledFailure !== undefined) {
|
if (settledFailure !== undefined) {
|
||||||
const failure = infraError ?? Cause.squash(settledFailure)
|
const failure = infraError ?? Cause.squash(settledFailure)
|
||||||
const error = toSessionError(failure)
|
const error = toSessionError(failure)
|
||||||
|
|
|
||||||
|
|
@ -410,7 +410,6 @@ export const createLLMEventPublisher = (
|
||||||
if (event.result.type === "error") return
|
if (event.result.type === "error") return
|
||||||
return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`))
|
return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`))
|
||||||
}
|
}
|
||||||
tool.settled = true
|
|
||||||
const result = error ? { error } : settledOutput(event.output, event.result)
|
const result = error ? { error } : settledOutput(event.output, event.result)
|
||||||
const executed = event.providerExecuted === true || tool.providerExecuted
|
const executed = event.providerExecuted === true || tool.providerExecuted
|
||||||
const resultState = providerState(event.providerMetadata)
|
const resultState = providerState(event.providerMetadata)
|
||||||
|
|
@ -424,12 +423,13 @@ export const createLLMEventPublisher = (
|
||||||
executed,
|
executed,
|
||||||
resultState,
|
resultState,
|
||||||
})
|
})
|
||||||
|
tool.settled = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const bounded = yield* outputs.bound({
|
const bounded = yield* outputs.bound({
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
callID: event.id,
|
callID: event.id,
|
||||||
output: result,
|
output: { ...result, structured: record(result.structured) },
|
||||||
})
|
})
|
||||||
yield* events.publish(SessionEvent.Tool.Success, {
|
yield* events.publish(SessionEvent.Tool.Success, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
|
|
@ -437,10 +437,12 @@ export const createLLMEventPublisher = (
|
||||||
callID: event.id,
|
callID: event.id,
|
||||||
structured: record(bounded.output.structured),
|
structured: record(bounded.output.structured),
|
||||||
content: bounded.output.content,
|
content: bounded.output.content,
|
||||||
|
// Provider-executed results remain verbatim for provider-history replay.
|
||||||
...(executed ? { result: event.result } : {}),
|
...(executed ? { result: event.result } : {}),
|
||||||
executed,
|
executed,
|
||||||
resultState,
|
resultState,
|
||||||
})
|
})
|
||||||
|
tool.settled = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "tool-error": {
|
case "tool-error": {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { FSUtil } from "../fs-util"
|
||||||
import { LocationMutation } from "../location-mutation"
|
import { LocationMutation } from "../location-mutation"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
|
import { ToolStructured } from "./structured"
|
||||||
|
|
||||||
export const name = "edit"
|
export const name = "edit"
|
||||||
|
|
||||||
|
|
@ -103,6 +104,15 @@ export const Plugin = {
|
||||||
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
|
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
|
structured: Output,
|
||||||
|
toStructuredOutput: ({ output }) =>
|
||||||
|
ToolStructured.fit((maximumBytes) => ({
|
||||||
|
...output,
|
||||||
|
files: output.files.map((file) => ({
|
||||||
|
...file,
|
||||||
|
patch: ToolStructured.truncate(file.patch, maximumBytes),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
toModelOutput: ({ input, output }) => [
|
toModelOutput: ({ input, output }) => [
|
||||||
{ type: "text", text: toModelOutput(output, input.oldString, input.newString) },
|
{ type: "text", text: toModelOutput(output, input.oldString, input.newString) },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function codeModeTool(tool: AnyTool): AnyTool {
|
function codeModeTool(tool: AnyTool): AnyTool {
|
||||||
if ("jsonSchema" in tool) return tool
|
if ("jsonSchema" in tool || tool.codeModeOutput !== "output") return tool
|
||||||
return { ...tool, structured: undefined }
|
return { ...tool, structured: undefined }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ export const Plugin = {
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
structured: StructuredOutput,
|
structured: StructuredOutput,
|
||||||
|
codeModeOutput: "output",
|
||||||
toStructuredOutput: ({ output }) => ({ count: output.length }),
|
toStructuredOutput: ({ output }) => ({ count: output.length }),
|
||||||
toModelOutput: ({ output }) => [
|
toModelOutput: ({ output }) => [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ export const Plugin = {
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
structured: StructuredOutput,
|
structured: StructuredOutput,
|
||||||
|
codeModeOutput: "output",
|
||||||
toStructuredOutput: ({ output }) => ({ matches: output.length }),
|
toStructuredOutput: ({ output }) => ({ matches: output.length }),
|
||||||
toModelOutput: ({ output }) => [
|
toModelOutput: ({ output }) => [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { LocationMutation } from "../location-mutation"
|
||||||
import { Patch } from "../patch"
|
import { Patch } from "../patch"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
|
import { ToolStructured } from "./structured"
|
||||||
|
|
||||||
export const name = "patch"
|
export const name = "patch"
|
||||||
|
|
||||||
|
|
@ -72,6 +73,15 @@ export const Plugin = {
|
||||||
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
|
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
|
structured: Output,
|
||||||
|
toStructuredOutput: ({ output }) =>
|
||||||
|
ToolStructured.fit((maximumBytes) => ({
|
||||||
|
...output,
|
||||||
|
files: output.files.map((file) => ({
|
||||||
|
...file,
|
||||||
|
patch: ToolStructured.truncate(file.patch, maximumBytes),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
||||||
execute: (input, context) => {
|
execute: (input, context) => {
|
||||||
const applied: Array<typeof Applied.Type> = []
|
const applied: Array<typeof Applied.Type> = []
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { Form } from "../form"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
import { QuestionV2 } from "../question"
|
import { QuestionV2 } from "../question"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
|
import { ToolStructured } from "./structured"
|
||||||
|
|
||||||
export const name = "question"
|
export const name = "question"
|
||||||
|
|
||||||
|
|
@ -63,6 +64,13 @@ export const Plugin = {
|
||||||
description,
|
description,
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
|
structured: Output,
|
||||||
|
toStructuredOutput: ({ output }) =>
|
||||||
|
ToolStructured.fit((maximumBytes) => ({
|
||||||
|
answers: output.answers.map((answers) =>
|
||||||
|
answers.map((answer) => ToolStructured.truncate(answer, maximumBytes)),
|
||||||
|
),
|
||||||
|
})),
|
||||||
toModelOutput: ({ input, output }) => [
|
toModelOutput: ({ input, output }) => [
|
||||||
{ type: "text", text: toModelOutput(input.questions, output.answers) },
|
{ type: "text", text: toModelOutput(input.questions, output.answers) },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ export const Plugin = {
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
structured: StructuredOutput,
|
structured: StructuredOutput,
|
||||||
|
codeModeOutput: "output",
|
||||||
toStructuredOutput: ({ output }) => {
|
toStructuredOutput: ({ output }) => {
|
||||||
if ("encoding" in output)
|
if ("encoding" in output)
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -118,8 +118,7 @@ const registryLayer = Layer.effect(
|
||||||
|
|
||||||
const settleTool = Effect.fn("ToolRegistry.settleTool")(function* (input: ExecuteInput, tool: AnyTool) {
|
const settleTool = Effect.fn("ToolRegistry.settleTool")(function* (input: ExecuteInput, tool: AnyTool) {
|
||||||
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleTool.
|
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleTool.
|
||||||
const propagateTruncation =
|
const propagateTruncation = !("jsonSchema" in tool) && tool.contentTruncation === true
|
||||||
!("jsonSchema" in tool) && tool.structured !== undefined && tool.toStructuredOutput !== undefined
|
|
||||||
const beforeEvent: ToolHooks.BeforeEvent = {
|
const beforeEvent: ToolHooks.BeforeEvent = {
|
||||||
tool: input.call.name,
|
tool: input.call.name,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
|
|
@ -200,7 +199,6 @@ const registryLayer = Layer.effect(
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
callID: input.call.id,
|
callID: input.call.id,
|
||||||
output: afterEvent.output,
|
output: afterEvent.output,
|
||||||
propagateTruncation,
|
|
||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
const outputPaths = [
|
const outputPaths = [
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,7 @@ export const Plugin = {
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
structured: StructuredOutput,
|
structured: StructuredOutput,
|
||||||
|
codeModeOutput: "output",
|
||||||
toStructuredOutput: ({ output }) => ({
|
toStructuredOutput: ({ output }) => ({
|
||||||
truncated: output.truncated,
|
truncated: output.truncated,
|
||||||
...(output.exit === undefined ? {} : { exit: output.exit }),
|
...(output.exit === undefined ? {} : { exit: output.exit }),
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,8 @@ export const Plugin = {
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
structured: StructuredOutput,
|
structured: StructuredOutput,
|
||||||
|
codeModeOutput: "output",
|
||||||
|
contentTruncation: true,
|
||||||
toStructuredOutput: ({ output }) => ({
|
toStructuredOutput: ({ output }) => ({
|
||||||
name: output.name,
|
name: output.name,
|
||||||
directory: output.directory,
|
directory: output.directory,
|
||||||
|
|
|
||||||
39
packages/core/src/tool/structured.ts
Normal file
39
packages/core/src/tool/structured.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
export * as ToolStructured from "./structured"
|
||||||
|
|
||||||
|
import { ToolOutputStore } from "../tool-output-store"
|
||||||
|
|
||||||
|
export function fit<A>(project: (maxStringBytes: number) => A) {
|
||||||
|
const full = project(ToolOutputStore.MAX_STRUCTURED_BYTES)
|
||||||
|
if (Buffer.byteLength(JSON.stringify(full), "utf-8") <= ToolOutputStore.MAX_STRUCTURED_BYTES) return full
|
||||||
|
|
||||||
|
let minimum = 0
|
||||||
|
let maximum = ToolOutputStore.MAX_STRUCTURED_BYTES
|
||||||
|
let result = project(0)
|
||||||
|
while (minimum <= maximum) {
|
||||||
|
const middle = Math.floor((minimum + maximum) / 2)
|
||||||
|
const candidate = project(middle)
|
||||||
|
if (Buffer.byteLength(JSON.stringify(candidate), "utf-8") <= ToolOutputStore.MAX_STRUCTURED_BYTES) {
|
||||||
|
result = candidate
|
||||||
|
minimum = middle + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
maximum = middle - 1
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(input: string, maximumBytes: number) {
|
||||||
|
if (Buffer.byteLength(input, "utf-8") <= maximumBytes) return input
|
||||||
|
const marker = " ... truncated ..."
|
||||||
|
const markerBytes = Buffer.byteLength(marker, "utf-8")
|
||||||
|
const available = Math.max(0, maximumBytes - markerBytes)
|
||||||
|
let bytes = 0
|
||||||
|
let end = 0
|
||||||
|
for (const char of input) {
|
||||||
|
const size = Buffer.byteLength(char, "utf-8")
|
||||||
|
if (bytes + size > available) break
|
||||||
|
bytes += size
|
||||||
|
end += char.length
|
||||||
|
}
|
||||||
|
return input.slice(0, end).replace(/\r?\n$/, "") + (maximumBytes >= markerBytes ? marker : "")
|
||||||
|
}
|
||||||
|
|
@ -123,6 +123,7 @@ export const Plugin = {
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
structured: StructuredOutput,
|
structured: StructuredOutput,
|
||||||
|
contentTruncation: true,
|
||||||
toStructuredOutput: ({ output }) => ({
|
toStructuredOutput: ({ output }) => ({
|
||||||
sessionID: output.sessionID,
|
sessionID: output.sessionID,
|
||||||
status: output.status,
|
status: output.status,
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,8 @@ export const Plugin = {
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
structured: StructuredOutput,
|
structured: StructuredOutput,
|
||||||
|
codeModeOutput: "output",
|
||||||
|
contentTruncation: true,
|
||||||
toStructuredOutput: ({ input, output }) => ({
|
toStructuredOutput: ({ input, output }) => ({
|
||||||
url: output.url,
|
url: output.url,
|
||||||
contentType: output.contentType,
|
contentType: output.contentType,
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,8 @@ export const Plugin = {
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
structured: StructuredOutput,
|
structured: StructuredOutput,
|
||||||
|
codeModeOutput: "output",
|
||||||
|
contentTruncation: true,
|
||||||
toStructuredOutput: ({ output }) => ({
|
toStructuredOutput: ({ output }) => ({
|
||||||
provider: output.provider,
|
provider: output.provider,
|
||||||
bytes: Buffer.byteLength(output.text, "utf-8"),
|
bytes: Buffer.byteLength(output.text, "utf-8"),
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,45 @@ test("provider-executed success bounds output before durable publication", async
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("normalizes structured output to its durable record shape before bounding", async () => {
|
||||||
|
const guarded: ToolOutputStore.BoundInput[] = []
|
||||||
|
const outputStore: Pick<ToolOutputStore.Interface, "bound"> = {
|
||||||
|
bound: (input) => Effect.sync(() => guarded.push(input)).pipe(Effect.as({ output: input.output, outputPaths: [] })),
|
||||||
|
}
|
||||||
|
const { publisher } = capture("anthropic", outputStore)
|
||||||
|
await Effect.runPromise(publisher.publish(call))
|
||||||
|
await Effect.runPromise(
|
||||||
|
publisher.publish(
|
||||||
|
LLMEvent.toolResult({
|
||||||
|
...result,
|
||||||
|
output: { structured: "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES - 2), content: [] },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(guarded[0]?.output.structured).toEqual({
|
||||||
|
value: "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES - 2),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a bounding failure leaves the tool eligible for durable failure", async () => {
|
||||||
|
const storage = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
|
||||||
|
const outputStore: Pick<ToolOutputStore.Interface, "bound"> = {
|
||||||
|
bound: () => Effect.fail(storage),
|
||||||
|
}
|
||||||
|
const { published, publisher } = capture("anthropic", outputStore)
|
||||||
|
await Effect.runPromise(publisher.publish(call))
|
||||||
|
const exit = await Effect.runPromiseExit(publisher.publish(result))
|
||||||
|
expect(exit._tag).toBe("Failure")
|
||||||
|
|
||||||
|
await Effect.runPromise(publisher.failUnsettledTools({ type: "unknown", message: storage.message }))
|
||||||
|
expect(published.some((event) => event.type === "session.tool.success.1")).toBe(false)
|
||||||
|
expect(published.find((event) => event.type === "session.tool.failed.1")?.data).toMatchObject({
|
||||||
|
callID: call.id,
|
||||||
|
error: { type: "unknown", message: storage.message },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("provider metadata is flattened using the route key", async () => {
|
test("provider metadata is flattened using the route key", async () => {
|
||||||
const { published, publisher } = capture()
|
const { published, publisher } = capture()
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
|
import { parsePatch } from "diff"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||||
|
|
@ -174,6 +175,45 @@ describe("EditTool", () => {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("retains a bounded patch for large edits", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) => {
|
||||||
|
reset()
|
||||||
|
const target = path.join(tmp.path, "large.txt")
|
||||||
|
const before = "x".repeat(9_000)
|
||||||
|
const after = "y".repeat(9_000)
|
||||||
|
return Effect.promise(() => fs.writeFile(target, before)).pipe(
|
||||||
|
Effect.andThen(
|
||||||
|
withTool(tmp.path, (registry) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const settled = yield* settleTool(
|
||||||
|
registry,
|
||||||
|
call({ path: "large.txt", oldString: before, newString: after }),
|
||||||
|
)
|
||||||
|
const structured = Schema.decodeUnknownSync(EditTool.Output)(settled.output?.structured)
|
||||||
|
expect(Buffer.byteLength(JSON.stringify(structured))).toBeLessThanOrEqual(
|
||||||
|
ToolOutputStore.MAX_STRUCTURED_BYTES,
|
||||||
|
)
|
||||||
|
expect(() => parsePatch(structured.files[0]?.patch ?? "")).not.toThrow()
|
||||||
|
expect(structured).toMatchObject({
|
||||||
|
replacements: 1,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
file: "large.txt",
|
||||||
|
patch: expect.stringContaining("... truncated ..."),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("accepts an absolute file path inside the active Location", () =>
|
it.live("accepts an absolute file path inside the active Location", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ test("execute exposes typed internal output instead of the persisted receipt", a
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.Struct({ content: Schema.String, bytes: Schema.Number }),
|
output: Schema.Struct({ content: Schema.String, bytes: Schema.Number }),
|
||||||
structured: Schema.Struct({ bytes: Schema.Number }),
|
structured: Schema.Struct({ bytes: Schema.Number }),
|
||||||
|
codeModeOutput: "output",
|
||||||
toStructuredOutput: ({ output }) => ({ bytes: output.bytes }),
|
toStructuredOutput: ({ output }) => ({ bytes: output.bytes }),
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.content }],
|
toModelOutput: ({ output }) => [{ type: "text", text: output.content }],
|
||||||
execute: () => Effect.succeed({ content: "full payload", bytes: 12 }),
|
execute: () => Effect.succeed({ content: "full payload", bytes: 12 }),
|
||||||
|
|
@ -127,3 +128,35 @@ test("execute exposes typed internal output instead of the persisted receipt", a
|
||||||
expect(result.structured).toEqual({ toolCalls: [{ tool: "fetch", status: "completed" }] })
|
expect(result.structured).toEqual({ toolCalls: [{ tool: "fetch", status: "completed" }] })
|
||||||
expect(result.content).toEqual([{ type: "text", text: "full payload" }])
|
expect(result.content).toEqual([{ type: "text", text: "full payload" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("execute keeps the structured projection unless a tool opts into internal output", async () => {
|
||||||
|
const child = Tool.make({
|
||||||
|
description: "Fetch content",
|
||||||
|
input: Schema.Struct({}),
|
||||||
|
output: Schema.Struct({ content: Schema.String, bytes: Schema.Number }),
|
||||||
|
structured: Schema.Struct({ bytes: Schema.Number }),
|
||||||
|
toStructuredOutput: ({ output }) => ({ bytes: output.bytes }),
|
||||||
|
execute: () => Effect.succeed({ content: "internal", bytes: 8 }),
|
||||||
|
})
|
||||||
|
const execute = ExecuteTool.create(new Map([["fetch", { tool: child, name: "fetch" }]]))
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Tool.settle(
|
||||||
|
execute,
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
id: "call_execute",
|
||||||
|
name: "execute",
|
||||||
|
input: { code: "return await tools.fetch({})" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sessionID: Session.ID.make("ses_execute"),
|
||||||
|
agent: Agent.ID.make("build"),
|
||||||
|
messageID: SessionMessage.ID.make("msg_execute"),
|
||||||
|
callID: "call_execute",
|
||||||
|
progress: () => Effect.void,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.content).toEqual([{ type: "text", text: '{\n "bytes": 8\n}' }])
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
import { Deferred, Effect, Exit, Fiber, Layer, Schema } from "effect"
|
||||||
|
import { parsePatch } from "diff"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||||
|
|
@ -217,6 +218,40 @@ describe("PatchTool", () => {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("retains a bounded patch for large changes", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) => {
|
||||||
|
reset()
|
||||||
|
const target = path.join(tmp.path, "large.txt")
|
||||||
|
const before = "x".repeat(9_000)
|
||||||
|
const after = "y".repeat(9_000)
|
||||||
|
return Effect.promise(() => fs.writeFile(target, `${before}\n`)).pipe(
|
||||||
|
Effect.andThen(
|
||||||
|
withTool(tmp.path, (registry) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const settled = yield* settleTool(
|
||||||
|
registry,
|
||||||
|
call(`*** Begin Patch\n*** Update File: large.txt\n@@\n-${before}\n+${after}\n*** End Patch`),
|
||||||
|
)
|
||||||
|
const structured = Schema.decodeUnknownSync(PatchTool.Output)(settled.output?.structured)
|
||||||
|
expect(Buffer.byteLength(JSON.stringify(structured))).toBeLessThanOrEqual(
|
||||||
|
ToolOutputStore.MAX_STRUCTURED_BYTES,
|
||||||
|
)
|
||||||
|
expect(() => parsePatch(structured.files[0]?.patch ?? "")).not.toThrow()
|
||||||
|
expect(structured).toMatchObject({
|
||||||
|
applied: [{ type: "update", resource: "large.txt" }],
|
||||||
|
files: [{ file: "large.txt", patch: expect.stringContaining("... truncated ...") }],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("rejects moves before applying any hunk", () =>
|
it.live("rejects moves before applying any hunk", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ const assertions: PermissionV2.AssertInput[] = []
|
||||||
let captured: Form.CreateInput | undefined
|
let captured: Form.CreateInput | undefined
|
||||||
let reject = false
|
let reject = false
|
||||||
let deny = false
|
let deny = false
|
||||||
|
let firstAnswer = "Build"
|
||||||
const capturedInput = () => captured
|
const capturedInput = () => captured
|
||||||
const questionInput = {
|
const questionInput = {
|
||||||
questions: [
|
questions: [
|
||||||
|
|
@ -63,7 +64,7 @@ const form = Layer.succeed(
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
Effect.sync(
|
Effect.sync(
|
||||||
(): Form.TerminalState =>
|
(): Form.TerminalState =>
|
||||||
reject ? { status: "cancelled" } : { status: "answered", answer: { q0: "Build", q1: ["Dev"] } },
|
reject ? { status: "cancelled" } : { status: "answered", answer: { q0: firstAnswer, q1: ["Dev"] } },
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -122,6 +123,7 @@ describe("QuestionTool", () => {
|
||||||
captured = undefined
|
captured = undefined
|
||||||
reject = false
|
reject = false
|
||||||
deny = false
|
deny = false
|
||||||
|
firstAnswer = "Build"
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
const questions = [
|
const questions = [
|
||||||
{
|
{
|
||||||
|
|
@ -200,6 +202,27 @@ describe("QuestionTool", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("retains bounded answers for long custom responses", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
reject = false
|
||||||
|
deny = false
|
||||||
|
firstAnswer = "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES)
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
const settled = yield* settleTool(registry, {
|
||||||
|
sessionID,
|
||||||
|
...toolIdentity,
|
||||||
|
call: { type: "tool-call", id: "call-question-large", name: "question", input: questionInput },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(Buffer.byteLength(JSON.stringify(settled.output?.structured))).toBeLessThanOrEqual(
|
||||||
|
ToolOutputStore.MAX_STRUCTURED_BYTES,
|
||||||
|
)
|
||||||
|
expect(settled.output?.structured).toMatchObject({
|
||||||
|
answers: [[expect.stringContaining("... truncated ...")]],
|
||||||
|
})
|
||||||
|
}).pipe(Effect.ensuring(Effect.sync(() => (firstAnswer = "Build")))),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("does not invent tool ownership metadata without a durable registry source", () =>
|
it.effect("does not invent tool ownership metadata without a durable registry source", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
captured = undefined
|
captured = undefined
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,10 @@ export type Definition<
|
||||||
readonly input: Input
|
readonly input: Input
|
||||||
readonly output: Output
|
readonly output: Output
|
||||||
readonly structured?: Structured
|
readonly structured?: Structured
|
||||||
|
/** Expose encoded output to CodeMode instead of the persisted structured projection. */
|
||||||
|
readonly codeModeOutput?: "output"
|
||||||
|
/** Mark a receipt's `truncated` field when its model-facing text is bounded. */
|
||||||
|
readonly contentTruncation?: true
|
||||||
readonly permission?: string
|
readonly permission?: string
|
||||||
readonly toStructuredOutput?: (input: {
|
readonly toStructuredOutput?: (input: {
|
||||||
readonly input: InputValue<Input>
|
readonly input: InputValue<Input>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue