mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 03:19:59 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf7f182091 | |||
| a9bc065996 | |||
| 98be51b74c | |||
| f7a72fdf32 | |||
| aa1f91e0d0 | |||
| c781ef420c | |||
| 74993a4bcf | |||
| 2deaa7786c |
@@ -40,6 +40,7 @@ const layer = Layer.effect(
|
|||||||
const db = (yield* Database.Service).db
|
const db = (yield* Database.Service).db
|
||||||
const compaction = yield* SessionCompaction.Service
|
const compaction = yield* SessionCompaction.Service
|
||||||
const title = yield* SessionTitle.Service
|
const title = yield* SessionTitle.Service
|
||||||
|
const outputs = yield* ToolOutputStore.Service
|
||||||
// Title generation is a side effect of the first step; it must not delay step continuation.
|
// Title generation is a side effect of the first step; it must not delay step continuation.
|
||||||
// Tracked per process so repeated wakes before the second user message arrives don't
|
// Tracked per process so repeated wakes before the second user message arrives don't
|
||||||
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
|
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
|
||||||
@@ -116,7 +117,7 @@ const layer = Layer.effect(
|
|||||||
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
|
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
|
||||||
let needsContinuation = false
|
let needsContinuation = false
|
||||||
const startSnapshot = yield* snapshots.capture()
|
const startSnapshot = yield* snapshots.capture()
|
||||||
const publisher = createLLMEventPublisher(events, {
|
const publisher = createLLMEventPublisher(events, outputs, {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
agent: agent.id,
|
agent: agent.id,
|
||||||
// The selected catalog identity, not model.id: route-level ids are provider API
|
// The selected catalog identity, not model.id: route-level ids are provider API
|
||||||
@@ -165,13 +166,25 @@ const layer = Layer.effect(
|
|||||||
call: event,
|
call: event,
|
||||||
progress: (update) =>
|
progress: (update) =>
|
||||||
serialized(
|
serialized(
|
||||||
events.publish(SessionEvent.Tool.Progress, {
|
Effect.gen(function* () {
|
||||||
sessionID: session.id,
|
const bounded = yield* outputs.bound({
|
||||||
assistantMessageID,
|
sessionID: session.id,
|
||||||
callID: event.id,
|
callID: event.id,
|
||||||
structured: { ...update.structured },
|
output: update,
|
||||||
content: [...update.content],
|
})
|
||||||
}),
|
const structured = bounded.output.structured
|
||||||
|
const entries =
|
||||||
|
typeof structured === "object" && structured !== null && !Array.isArray(structured)
|
||||||
|
? Object.entries(structured)
|
||||||
|
: yield* Effect.die(new Error("Tool progress structured output must be an object"))
|
||||||
|
yield* events.publish(SessionEvent.Tool.Progress, {
|
||||||
|
sessionID: session.id,
|
||||||
|
assistantMessageID,
|
||||||
|
callID: event.id,
|
||||||
|
structured: Object.fromEntries(entries),
|
||||||
|
content: [...bounded.output.content],
|
||||||
|
})
|
||||||
|
}).pipe(Effect.orDie),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
).pipe(
|
).pipe(
|
||||||
@@ -261,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()
|
||||||
|
|
||||||
@@ -287,8 +305,21 @@ 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 infrastructureFailure = settledCauses.find(
|
||||||
|
(cause) =>
|
||||||
|
Option.getOrUndefined(Cause.findErrorOption(cause)) instanceof ToolOutputStore.StorageError ||
|
||||||
|
cause.reasons.some(
|
||||||
|
(reason) => Cause.isDieReason(reason) && reason.defect instanceof ToolOutputStore.StorageError,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const typedError = infrastructureFailure
|
||||||
|
? Option.getOrUndefined(Cause.findErrorOption(infrastructureFailure))
|
||||||
|
: undefined
|
||||||
|
const storageDefect = infrastructureFailure?.reasons.find(
|
||||||
|
(reason) => Cause.isDieReason(reason) && reason.defect instanceof ToolOutputStore.StorageError,
|
||||||
|
)
|
||||||
const infraError =
|
const infraError =
|
||||||
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
|
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)
|
||||||
@@ -342,8 +373,8 @@ const layer = Layer.effect(
|
|||||||
|
|
||||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||||
if (userDeclined) return yield* Effect.interrupt
|
if (userDeclined) return yield* Effect.interrupt
|
||||||
if ((toolsInterrupted || infraError !== undefined) && settledFailure)
|
if (infraError !== undefined && infrastructureFailure) return yield* Effect.failCause(infrastructureFailure)
|
||||||
return yield* Effect.failCause(settledFailure)
|
if (toolsInterrupted && settledFailure) return yield* Effect.failCause(settledFailure)
|
||||||
if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
|
if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
|
||||||
if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
|
if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
|
||||||
return {
|
return {
|
||||||
@@ -500,6 +531,7 @@ export const node = makeLocationNode({
|
|||||||
SessionCompaction.node,
|
SessionCompaction.node,
|
||||||
SessionTitle.node,
|
SessionTitle.node,
|
||||||
Snapshot.node,
|
Snapshot.node,
|
||||||
|
ToolOutputStore.node,
|
||||||
Database.node,
|
Database.node,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { AgentV2 } from "../../agent"
|
|||||||
import { Snapshot } from "../../snapshot"
|
import { Snapshot } from "../../snapshot"
|
||||||
import { RelativePath } from "../../schema"
|
import { RelativePath } from "../../schema"
|
||||||
import { SessionUsage } from "../usage"
|
import { SessionUsage } from "../usage"
|
||||||
|
import { ToolOutputStore } from "../../tool-output-store"
|
||||||
|
|
||||||
type Input = {
|
type Input = {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
@@ -34,18 +35,22 @@ const message = (value: unknown) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SettledOutput =
|
type SettledOutput =
|
||||||
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
|
| ToolOutput
|
||||||
| { readonly error: SessionError.Error }
|
| { readonly error: SessionError.Error }
|
||||||
|
|
||||||
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
|
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
|
||||||
if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } }
|
if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } }
|
||||||
const settled = value ?? ToolOutput.fromResultValue(result)
|
const settled = value ?? ToolOutput.fromResultValue(result)
|
||||||
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
|
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
|
||||||
return { structured: record(settled.structured), content: settled.content }
|
return settled
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist one step without executing tools or starting a continuation step. */
|
/** Persist one step without executing tools or starting a continuation step. */
|
||||||
export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish">, input: Input) => {
|
export const createLLMEventPublisher = (
|
||||||
|
events: Pick<EventV2.Interface, "publish">,
|
||||||
|
outputs: Pick<ToolOutputStore.Interface, "bound">,
|
||||||
|
input: Input,
|
||||||
|
) => {
|
||||||
const tools = new Map<
|
const tools = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@@ -405,7 +410,6 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
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)
|
||||||
@@ -419,17 +423,26 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
executed,
|
executed,
|
||||||
resultState,
|
resultState,
|
||||||
})
|
})
|
||||||
|
tool.settled = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const bounded = yield* outputs.bound({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
callID: event.id,
|
||||||
|
output: { ...result, structured: record(result.structured) },
|
||||||
|
})
|
||||||
yield* events.publish(SessionEvent.Tool.Success, {
|
yield* events.publish(SessionEvent.Tool.Success, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
assistantMessageID: tool.assistantMessageID,
|
assistantMessageID: tool.assistantMessageID,
|
||||||
callID: event.id,
|
callID: event.id,
|
||||||
...result,
|
structured: record(bounded.output.structured),
|
||||||
|
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": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export * as ToolOutputStore from "./tool-output-store"
|
export * as ToolOutputStore from "./tool-output-store"
|
||||||
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
|
import { Context, Duration, Effect, Layer, Option, Predicate, Schedule, Schema } from "effect"
|
||||||
import { Config } from "./config"
|
import { Config } from "./config"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Global } from "./global"
|
import { Global } from "./global"
|
||||||
@@ -12,14 +12,21 @@ import type { ToolOutput } from "@opencode-ai/ai"
|
|||||||
|
|
||||||
export const MAX_LINES = 2_000
|
export const MAX_LINES = 2_000
|
||||||
export const MAX_BYTES = 50 * 1024
|
export const MAX_BYTES = 50 * 1024
|
||||||
|
export const MAX_STRUCTURED_BYTES = 16 * 1024
|
||||||
export const RETENTION = Duration.days(7)
|
export const RETENTION = Duration.days(7)
|
||||||
|
|
||||||
export const MANAGED_DIRECTORY = "tool-output"
|
export const MANAGED_DIRECTORY = "tool-output"
|
||||||
|
|
||||||
|
export interface Limits {
|
||||||
|
readonly maxLines: number
|
||||||
|
readonly maxBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface BoundInput {
|
export interface BoundInput {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly callID: string
|
readonly callID: string
|
||||||
readonly output: ToolOutput
|
readonly output: ToolOutput
|
||||||
|
readonly propagateTruncation?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BoundResult {
|
export interface BoundResult {
|
||||||
@@ -40,7 +47,7 @@ export class StorageError extends Schema.TaggedErrorClass<StorageError>()("ToolO
|
|||||||
export type Error = StorageError
|
export type Error = StorageError
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
|
readonly limits: () => Effect.Effect<Limits>
|
||||||
readonly bound: (input: BoundInput) => Effect.Effect<BoundResult, Error>
|
readonly bound: (input: BoundInput) => Effect.Effect<BoundResult, Error>
|
||||||
readonly cleanup: () => Effect.Effect<void>
|
readonly cleanup: () => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
@@ -49,26 +56,31 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||||||
|
|
||||||
const takePrefix = (input: string, maximumBytes: number) => {
|
const takePrefix = (input: string, maximumBytes: number) => {
|
||||||
let bytes = 0
|
let bytes = 0
|
||||||
let content = ""
|
let end = 0
|
||||||
for (const char of input) {
|
for (const char of input) {
|
||||||
const size = Buffer.byteLength(char, "utf-8")
|
const size = Buffer.byteLength(char, "utf-8")
|
||||||
if (bytes + size > maximumBytes) break
|
if (bytes + size > maximumBytes) break
|
||||||
content += char
|
end += char.length
|
||||||
bytes += size
|
bytes += size
|
||||||
}
|
}
|
||||||
return content
|
return input.slice(0, end)
|
||||||
}
|
}
|
||||||
|
|
||||||
const takeSuffix = (input: string, maximumBytes: number) => {
|
const takeSuffix = (input: string, maximumBytes: number) => {
|
||||||
let bytes = 0
|
let bytes = 0
|
||||||
const content: string[] = []
|
let start = input.length
|
||||||
for (const char of Array.from(input).toReversed()) {
|
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")
|
const size = Buffer.byteLength(char, "utf-8")
|
||||||
if (bytes + size > maximumBytes) break
|
if (bytes + size > maximumBytes) break
|
||||||
content.unshift(char)
|
start = next
|
||||||
bytes += size
|
bytes += size
|
||||||
}
|
}
|
||||||
return content.join("")
|
return input.slice(start)
|
||||||
}
|
}
|
||||||
|
|
||||||
const preview = (text: string, maxLines: number, maxBytes: number) => {
|
const preview = (text: string, maxLines: number, maxBytes: number) => {
|
||||||
@@ -109,6 +121,27 @@ const lineCount = (text: string) => {
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const contextualOverflow = (output: ToolOutput, limits: Limits) => {
|
||||||
|
const contextual = output.content
|
||||||
|
.filter((item) => item.type === "text")
|
||||||
|
.map((item) => item.text)
|
||||||
|
.join("")
|
||||||
|
return Buffer.byteLength(contextual, "utf-8") > limits.maxBytes || lineCount(contextual) > limits.maxLines
|
||||||
|
}
|
||||||
|
|
||||||
|
export const propagateTruncation = (output: ToolOutput, limits: Limits): ToolOutput =>
|
||||||
|
contextualOverflow(output, limits) &&
|
||||||
|
Predicate.isObject(output.structured) &&
|
||||||
|
"truncated" in output.structured &&
|
||||||
|
typeof output.structured.truncated === "boolean"
|
||||||
|
? { ...output, structured: { ...output.structured, truncated: true } }
|
||||||
|
: output
|
||||||
|
|
||||||
|
const record = (value: unknown): Record<string, unknown> =>
|
||||||
|
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
? Object.fromEntries(Object.entries(value))
|
||||||
|
: { value }
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -128,7 +161,6 @@ const layer = Layer.effect(
|
|||||||
|
|
||||||
const write = Effect.fn("ToolOutputStore.write")(function* (content: string) {
|
const write = Effect.fn("ToolOutputStore.write")(function* (content: string) {
|
||||||
const file = path.join(directory, `tool_${Identifier.ascending()}`)
|
const file = path.join(directory, `tool_${Identifier.ascending()}`)
|
||||||
yield* fs.ensureDir(directory).pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
|
|
||||||
yield* fs
|
yield* fs
|
||||||
.writeFileString(file, content, { flag: "wx" })
|
.writeFileString(file, content, { flag: "wx" })
|
||||||
.pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
|
.pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
|
||||||
@@ -139,37 +171,67 @@ const layer = Layer.effect(
|
|||||||
const outputLimits = yield* limits()
|
const outputLimits = yield* limits()
|
||||||
const media = input.output.content.filter((item) => item.type === "file")
|
const media = input.output.content.filter((item) => item.type === "file")
|
||||||
const text = input.output.content.filter((item) => item.type === "text")
|
const text = input.output.content.filter((item) => item.type === "text")
|
||||||
const contextual =
|
const encoded = yield* Effect.try({
|
||||||
input.output.content.length === 0
|
try: () => JSON.stringify(record(input.output.structured)),
|
||||||
? yield* Effect.try({
|
catch: (cause) => new StorageError({ operation: "encode", cause }),
|
||||||
try: () => JSON.stringify(input.output.structured, null, 2) ?? String(input.output.structured),
|
})
|
||||||
catch: (cause) => new StorageError({ operation: "encode", cause }),
|
const decoded: unknown = JSON.parse(encoded)
|
||||||
})
|
const structured =
|
||||||
: text.map((item) => item.text).join("")
|
typeof decoded === "object" && decoded !== null && !Array.isArray(decoded)
|
||||||
if (
|
? Object.fromEntries(Object.entries(decoded))
|
||||||
lineCount(contextual) <= outputLimits.maxLines &&
|
: yield* Effect.die(new Error("Durable tool structured output must be an object"))
|
||||||
Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes
|
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 contentOverflow = contextualBytes > outputLimits.maxBytes || lineCount(contextual) > outputLimits.maxLines
|
||||||
|
if (!structuredOverflow && !contentOverflow)
|
||||||
return {
|
return {
|
||||||
output: input.output,
|
output: { ...input.output, structured },
|
||||||
outputPaths: [],
|
outputPaths: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputPath = yield* write(contextual)
|
yield* fs.ensureDir(directory).pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
|
||||||
const marker = `... output truncated; full content saved to ${outputPath} ...`
|
const structuredPath = structuredOverflow ? yield* write(encoded) : undefined
|
||||||
|
const contextualPath = contentOverflow
|
||||||
|
? input.output.content.length === 0 && structuredPath
|
||||||
|
? structuredPath
|
||||||
|
: yield* write(contextual).pipe(
|
||||||
|
Effect.onError(() =>
|
||||||
|
structuredPath ? fs.remove(structuredPath).pipe(Effect.catch(() => Effect.void)) : Effect.void,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
output: {
|
output: {
|
||||||
structured: input.output.structured,
|
structured: structuredPath
|
||||||
content: [
|
? { _truncated: true, _bytes: encodedBytes, _outputPath: structuredPath }
|
||||||
{
|
: contentOverflow &&
|
||||||
type: "text" as const,
|
input.propagateTruncation === true &&
|
||||||
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
|
Predicate.isObject(structured) &&
|
||||||
},
|
"truncated" in structured &&
|
||||||
...media,
|
typeof structured.truncated === "boolean"
|
||||||
],
|
? { ...structured, truncated: true }
|
||||||
|
: structured,
|
||||||
|
content: contentOverflow
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
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))],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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.patch(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) },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
|||||||
) => {
|
) => {
|
||||||
const tools: Record<string, Tool.Definition<never>> = {}
|
const tools: Record<string, Tool.Definition<never>> = {}
|
||||||
for (const [name, registration] of registrations) {
|
for (const [name, registration] of registrations) {
|
||||||
const child = definition(name, registration.tool)
|
const child = definition(name, codeModeTool(registration.tool))
|
||||||
const value = Tool.make({
|
const value = Tool.make({
|
||||||
description: child.description,
|
description: child.description,
|
||||||
input: child.inputSchema,
|
input: child.inputSchema,
|
||||||
@@ -96,7 +96,7 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
|
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
|
||||||
const output = yield* settle(
|
const output = yield* settle(
|
||||||
registration.tool,
|
codeModeTool(registration.tool),
|
||||||
{ type: "tool-call", id: context.callID, name, input },
|
{ type: "tool-call", id: context.callID, name, input },
|
||||||
{
|
{
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
@@ -141,6 +141,11 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function codeModeTool(tool: AnyTool): AnyTool {
|
||||||
|
if ("jsonSchema" in tool || tool.codeModeOutput !== "output") return tool
|
||||||
|
return { ...tool, structured: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
function displayInput(input: unknown): Record<string, unknown> | undefined {
|
function displayInput(input: unknown): Record<string, unknown> | undefined {
|
||||||
if (input === null || input === undefined) return
|
if (input === null || input === undefined) return
|
||||||
if (typeof input !== "object" || Array.isArray(input)) return { input }
|
if (typeof input !== "object" || Array.isArray(input)) return { input }
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const Input = Schema.Struct({
|
|||||||
|
|
||||||
export const Output = Schema.Array(FileSystem.Entry)
|
export const Output = Schema.Array(FileSystem.Entry)
|
||||||
type ModelOutput = typeof Output.Encoded
|
type ModelOutput = typeof Output.Encoded
|
||||||
|
const StructuredOutput = Schema.Struct({ count: Schema.Number })
|
||||||
|
|
||||||
/** Format raw search results into the concise line-oriented output models expect. */
|
/** Format raw search results into the concise line-oriented output models expect. */
|
||||||
export const toModelOutput = (output: ModelOutput) => {
|
export const toModelOutput = (output: ModelOutput) => {
|
||||||
@@ -51,6 +52,9 @@ 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.",
|
"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,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
|
structured: StructuredOutput,
|
||||||
|
codeModeOutput: "output",
|
||||||
|
toStructuredOutput: ({ output }) => ({ count: output.length }),
|
||||||
toModelOutput: ({ output }) => [
|
toModelOutput: ({ output }) => [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const Input = Schema.Struct({
|
|||||||
|
|
||||||
export const Output = Schema.Array(FileSystem.Match)
|
export const Output = Schema.Array(FileSystem.Match)
|
||||||
type ModelOutput = typeof Output.Encoded
|
type ModelOutput = typeof Output.Encoded
|
||||||
|
const StructuredOutput = Schema.Struct({ matches: Schema.Number })
|
||||||
|
|
||||||
/** Format raw search matches into the familiar concise model output. */
|
/** Format raw search matches into the familiar concise model output. */
|
||||||
export const toModelOutput = (output: ModelOutput) => {
|
export const toModelOutput = (output: ModelOutput) => {
|
||||||
@@ -65,6 +66,9 @@ 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.",
|
"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,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
|
structured: StructuredOutput,
|
||||||
|
codeModeOutput: "output",
|
||||||
|
toStructuredOutput: ({ output }) => ({ matches: output.length }),
|
||||||
toModelOutput: ({ output }) => [
|
toModelOutput: ({ output }) => [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
|
|||||||
@@ -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.patch(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) },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
let line = 1
|
let line = 1
|
||||||
let bytes = 0
|
let bytes = 0
|
||||||
let next: number | undefined
|
let next: number | undefined
|
||||||
|
let lineTruncated = false
|
||||||
const append = (input: string) => {
|
const append = (input: string) => {
|
||||||
if (line < offset) {
|
if (line < offset) {
|
||||||
line++
|
line++
|
||||||
@@ -249,6 +250,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
|
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
|
||||||
|
if (text !== input) lineTruncated = true
|
||||||
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
|
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
|
||||||
if (bytes + size > MAX_READ_BYTES) {
|
if (bytes + size > MAX_READ_BYTES) {
|
||||||
next = line
|
next = line
|
||||||
@@ -314,7 +316,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
content: lines.join("\n"),
|
content: lines.join("\n"),
|
||||||
mime: FSUtil.mimeType(real),
|
mime: FSUtil.mimeType(real),
|
||||||
offset,
|
offset,
|
||||||
truncated: next !== undefined,
|
truncated: next !== undefined || lineTruncated,
|
||||||
...(next === undefined ? {} : { next }),
|
...(next === undefined ? {} : { next }),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -28,6 +28,31 @@ const LocationInput = Schema.Struct({
|
|||||||
})
|
})
|
||||||
const Input = LocationInput
|
const Input = LocationInput
|
||||||
const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
|
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 = {
|
export const Plugin = {
|
||||||
id: "opencode.tool.read",
|
id: "opencode.tool.read",
|
||||||
@@ -48,15 +73,39 @@ 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.",
|
"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,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
structured: Schema.toEncoded(Output),
|
structured: StructuredOutput,
|
||||||
// Image base64 reaches the model through content items (normalized generically
|
codeModeOutput: "output",
|
||||||
// at tool settlement); persisting a second copy in structured would store the
|
contentTruncation: true,
|
||||||
// original unresized bytes in the message row.
|
toStructuredOutput: ({ output }) => {
|
||||||
toStructuredOutput: ({ output }) =>
|
if ("encoding" in output)
|
||||||
"encoding" in output && output.encoding === "base64" ? { ...output, content: "" } : 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 }) => {
|
toModelOutput: ({ input, output }) => {
|
||||||
if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime))
|
if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime))
|
||||||
return []
|
return [{ type: "text", text: JSON.stringify(output) }]
|
||||||
return [
|
return [
|
||||||
{ type: "text", text: "Image read successfully" },
|
{ type: "text", text: "Image read successfully" },
|
||||||
{ type: "file", data: output.content, mime: output.mime, name: input.path },
|
{ type: "file", data: output.content, mime: output.mime, name: input.path },
|
||||||
|
|||||||
@@ -83,18 +83,16 @@ const registryLayer = Layer.effect(
|
|||||||
const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1]
|
const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1]
|
||||||
if (base64 === undefined) return Effect.succeed(item)
|
if (base64 === undefined) return Effect.succeed(item)
|
||||||
const resource = item.name ?? `${item.mime} tool output`
|
const resource = item.name ?? `${item.mime} tool output`
|
||||||
return image
|
return image.normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime }).pipe(
|
||||||
.normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime })
|
Effect.map((result) => ({
|
||||||
.pipe(
|
...item,
|
||||||
Effect.map((result) => ({
|
uri: `data:${result.mime};base64,${result.content}`,
|
||||||
...item,
|
mime: result.mime,
|
||||||
uri: `data:${result.mime};base64,${result.content}`,
|
})),
|
||||||
mime: result.mime,
|
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)),
|
||||||
})),
|
Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)),
|
||||||
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)),
|
Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)),
|
||||||
Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)),
|
)
|
||||||
Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
const note = (reason: "decode" | "size", text: string) => {
|
const note = (reason: "decode" | "size", text: string) => {
|
||||||
const count = normalized.filter((item) => item === reason).length
|
const count = normalized.filter((item) => item === reason).length
|
||||||
@@ -118,6 +116,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 = !("jsonSchema" in tool) && tool.contentTruncation === true
|
||||||
const beforeEvent: ToolHooks.BeforeEvent = {
|
const beforeEvent: ToolHooks.BeforeEvent = {
|
||||||
tool: input.call.name,
|
tool: input.call.name,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -149,7 +148,28 @@ const registryLayer = Layer.effect(
|
|||||||
name: part.name,
|
name: part.name,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
).pipe(Effect.flatMap((content) => progress({ structured: update.structured, content })))
|
).pipe(
|
||||||
|
Effect.flatMap((content) =>
|
||||||
|
propagateTruncation
|
||||||
|
? resources
|
||||||
|
.limits()
|
||||||
|
.pipe(
|
||||||
|
Effect.map((limits) =>
|
||||||
|
ToolOutputStore.propagateTruncation({ structured: update.structured, content }, limits),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Effect.succeed({ structured: update.structured, content }),
|
||||||
|
),
|
||||||
|
Effect.flatMap((output) => {
|
||||||
|
const structured = output.structured
|
||||||
|
const entries =
|
||||||
|
typeof structured === "object" && structured !== null && !Array.isArray(structured)
|
||||||
|
? Object.entries(structured)
|
||||||
|
: undefined
|
||||||
|
if (!entries) return Effect.die(new Error("Tool progress structured output must be an object"))
|
||||||
|
return progress({ structured: Object.fromEntries(entries), content: output.content })
|
||||||
|
}),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
).pipe(
|
).pipe(
|
||||||
@@ -165,21 +185,13 @@ const registryLayer = Layer.effect(
|
|||||||
if ("result" in pending) {
|
if ("result" in pending) {
|
||||||
settlement = pending
|
settlement = pending
|
||||||
} else {
|
} else {
|
||||||
const bounded = yield* resources.bound({
|
const output = {
|
||||||
sessionID: input.sessionID,
|
structured: pending.output.structured,
|
||||||
callID: input.call.id,
|
content: yield* normalizeImages(pending.output.content),
|
||||||
output: { structured: pending.output.structured, content: yield* normalizeImages(pending.output.content) },
|
}
|
||||||
})
|
settlement = { result: ToolOutput.toResultValue(output), output }
|
||||||
const result = ToolOutput.toResultValue(bounded.output)
|
|
||||||
settlement =
|
|
||||||
result.type === "error"
|
|
||||||
? bounded.outputPaths.length > 0
|
|
||||||
? { result, outputPaths: bounded.outputPaths }
|
|
||||||
: { result }
|
|
||||||
: bounded.outputPaths.length > 0
|
|
||||||
? { result, output: bounded.output, outputPaths: bounded.outputPaths }
|
|
||||||
: { result, output: bounded.output }
|
|
||||||
}
|
}
|
||||||
|
const initialResult = settlement.result
|
||||||
const afterEvent: ToolHooks.AfterEvent = {
|
const afterEvent: ToolHooks.AfterEvent = {
|
||||||
tool: input.call.name,
|
tool: input.call.name,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -192,10 +204,22 @@ const registryLayer = Layer.effect(
|
|||||||
outputPaths: settlement.outputPaths,
|
outputPaths: settlement.outputPaths,
|
||||||
}
|
}
|
||||||
yield* toolHooks.runAfter(afterEvent)
|
yield* toolHooks.runAfter(afterEvent)
|
||||||
|
const finalOutput = afterEvent.output
|
||||||
|
? yield* resources.bound({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
callID: input.call.id,
|
||||||
|
output: afterEvent.output,
|
||||||
|
propagateTruncation,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
const outputPaths = [...new Set([...(afterEvent.outputPaths ?? []), ...(finalOutput?.outputPaths ?? [])])]
|
||||||
return {
|
return {
|
||||||
result: afterEvent.result,
|
result:
|
||||||
...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}),
|
finalOutput !== undefined && afterEvent.result === initialResult && !("result" in pending)
|
||||||
...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}),
|
? ToolOutput.toResultValue(finalOutput.output)
|
||||||
|
: afterEvent.result,
|
||||||
|
...(finalOutput !== undefined ? { output: finalOutput.output } : {}),
|
||||||
|
...(outputPaths.length > 0 ? { outputPaths } : {}),
|
||||||
...(settlement.error !== undefined ? { error: settlement.error } : {}),
|
...(settlement.error !== undefined ? { error: settlement.error } : {}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ export const Plugin = {
|
|||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
structured: StructuredOutput,
|
structured: StructuredOutput,
|
||||||
|
codeModeOutput: "output",
|
||||||
|
contentTruncation: true,
|
||||||
toStructuredOutput: ({ output }) => ({
|
toStructuredOutput: ({ output }) => ({
|
||||||
truncated: output.truncated,
|
truncated: output.truncated,
|
||||||
...(output.exit === undefined ? {} : { exit: output.exit }),
|
...(output.exit === undefined ? {} : { exit: output.exit }),
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ export const Output = Schema.Struct({
|
|||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const StructuredOutput = Schema.Struct({
|
||||||
|
name: Schema.String,
|
||||||
|
directory: Schema.String,
|
||||||
|
bytes: Schema.Number,
|
||||||
|
truncated: Schema.Boolean,
|
||||||
|
})
|
||||||
|
|
||||||
export const description = [
|
export const description = [
|
||||||
"Load a specialized skill when the task at hand matches one of the available skills in the instructions.",
|
"Load a specialized skill when the task at hand matches one of the available skills in the instructions.",
|
||||||
"",
|
"",
|
||||||
@@ -66,6 +73,15 @@ export const Plugin = {
|
|||||||
description,
|
description,
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
|
structured: StructuredOutput,
|
||||||
|
codeModeOutput: "output",
|
||||||
|
contentTruncation: true,
|
||||||
|
toStructuredOutput: ({ output }) => ({
|
||||||
|
name: output.name,
|
||||||
|
directory: output.directory,
|
||||||
|
bytes: Buffer.byteLength(output.output, "utf-8"),
|
||||||
|
truncated: false,
|
||||||
|
}),
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
export * as ToolStructured from "./structured"
|
||||||
|
|
||||||
|
import { formatPatch, parsePatch } from "diff"
|
||||||
|
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 : "")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patch(input: string, maximumBytes: number) {
|
||||||
|
if (Buffer.byteLength(input, "utf-8") <= maximumBytes) return input
|
||||||
|
const parsed = parsePatch(input)[0]
|
||||||
|
const hunk = parsed?.hunks[0]
|
||||||
|
if (!parsed || !hunk) return truncate(input, maximumBytes)
|
||||||
|
const changedLines = parsed.hunks.flatMap((item) => item.lines).filter((line) => /^[+-]/.test(line))
|
||||||
|
const removed = changedLines.filter((line) => line.startsWith("-"))
|
||||||
|
const added = changedLines.filter((line) => line.startsWith("+"))
|
||||||
|
const changed = [removed[0], added[0]]
|
||||||
|
.filter((line) => line !== undefined)
|
||||||
|
.map((line) => line[0] + truncate(line.slice(1), Math.max(0, Math.floor(maximumBytes / 2) - 1)))
|
||||||
|
const lines = [
|
||||||
|
...changed,
|
||||||
|
...(removed.length > 1 ? [`-... ${removed.length - 1} removed lines omitted ...`] : []),
|
||||||
|
...(added.length > 1 ? [`+... ${added.length - 1} added lines omitted ...`] : []),
|
||||||
|
]
|
||||||
|
return formatPatch({
|
||||||
|
...parsed,
|
||||||
|
hunks: [
|
||||||
|
{
|
||||||
|
oldStart: hunk.oldStart,
|
||||||
|
oldLines: lines.filter((line) => line.startsWith("-") || line.startsWith(" ")).length,
|
||||||
|
newStart: hunk.newStart,
|
||||||
|
newLines: lines.filter((line) => line.startsWith("+") || line.startsWith(" ")).length,
|
||||||
|
lines,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -32,6 +32,13 @@ export const Output = Schema.Struct({
|
|||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const StructuredOutput = Schema.Struct({
|
||||||
|
sessionID: Output.fields.sessionID,
|
||||||
|
status: Output.fields.status,
|
||||||
|
bytes: Schema.Number,
|
||||||
|
truncated: Schema.Boolean,
|
||||||
|
})
|
||||||
|
|
||||||
export const description = [
|
export const description = [
|
||||||
"Spawn a subagent: a child session running a configured agent with fresh context.",
|
"Spawn a subagent: a child session running a configured agent with fresh context.",
|
||||||
"Foreground (default) runs the subagent to completion and returns its final response.",
|
"Foreground (default) runs the subagent to completion and returns its final response.",
|
||||||
@@ -115,6 +122,14 @@ export const Plugin = {
|
|||||||
description,
|
description,
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
|
structured: StructuredOutput,
|
||||||
|
contentTruncation: true,
|
||||||
|
toStructuredOutput: ({ output }) => ({
|
||||||
|
sessionID: output.sessionID,
|
||||||
|
status: output.status,
|
||||||
|
bytes: Buffer.byteLength(output.output, "utf-8"),
|
||||||
|
truncated: false,
|
||||||
|
}),
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ const Output = Schema.Struct({
|
|||||||
output: Schema.String,
|
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"]
|
type Format = (typeof Input.Type)["format"]
|
||||||
|
|
||||||
const acceptHeader = (format: Format) => {
|
const acceptHeader = (format: Format) => {
|
||||||
@@ -126,6 +134,16 @@ export const Plugin = {
|
|||||||
description,
|
description,
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
|
structured: StructuredOutput,
|
||||||
|
codeModeOutput: "output",
|
||||||
|
contentTruncation: true,
|
||||||
|
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 }],
|
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
||||||
execute: (input, context) =>
|
execute: (input, context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -187,6 +187,12 @@ const Output = Schema.Struct({
|
|||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const StructuredOutput = Schema.Struct({
|
||||||
|
provider: Provider,
|
||||||
|
bytes: Schema.Number,
|
||||||
|
truncated: Schema.Boolean,
|
||||||
|
})
|
||||||
|
|
||||||
export const Plugin = {
|
export const Plugin = {
|
||||||
id: "opencode.tool.websearch",
|
id: "opencode.tool.websearch",
|
||||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
@@ -202,6 +208,14 @@ export const Plugin = {
|
|||||||
description,
|
description,
|
||||||
input: Input,
|
input: Input,
|
||||||
output: Output,
|
output: Output,
|
||||||
|
structured: StructuredOutput,
|
||||||
|
codeModeOutput: "output",
|
||||||
|
contentTruncation: true,
|
||||||
|
toStructuredOutput: ({ output }) => ({
|
||||||
|
provider: output.provider,
|
||||||
|
bytes: Buffer.byteLength(output.text, "utf-8"),
|
||||||
|
truncated: false,
|
||||||
|
}),
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
||||||
execute: (input, context) => {
|
execute: (input, context) => {
|
||||||
const provider = selectProvider(context.sessionID, config, config.provider)
|
const provider = selectProvider(context.sessionID, config, config.provider)
|
||||||
|
|||||||
@@ -12,11 +12,15 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
|||||||
import { RelativePath } from "@opencode-ai/core/schema"
|
import { RelativePath } from "@opencode-ai/core/schema"
|
||||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||||
import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
|
import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
|
||||||
|
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||||
|
|
||||||
const sessionID = SessionV2.ID.make("ses_tool_event_test")
|
const sessionID = SessionV2.ID.make("ses_tool_event_test")
|
||||||
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
|
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
|
||||||
|
const outputs: Pick<ToolOutputStore.Interface, "bound"> = {
|
||||||
|
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
|
||||||
|
}
|
||||||
|
|
||||||
const capture = (providerMetadataKey = "anthropic") => {
|
const capture = (providerMetadataKey = "anthropic", outputStore = outputs) => {
|
||||||
const published: Array<{ readonly type: string; readonly data: unknown }> = []
|
const published: Array<{ readonly type: string; readonly data: unknown }> = []
|
||||||
const events: Pick<EventV2.Interface, "publish"> = {
|
const events: Pick<EventV2.Interface, "publish"> = {
|
||||||
publish: (definition, data) =>
|
publish: (definition, data) =>
|
||||||
@@ -33,7 +37,7 @@ const capture = (providerMetadataKey = "anthropic") => {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
published,
|
published,
|
||||||
publisher: createLLMEventPublisher(events, {
|
publisher: createLLMEventPublisher(events, outputStore, {
|
||||||
sessionID,
|
sessionID,
|
||||||
agent: AgentV2.ID.make("build"),
|
agent: AgentV2.ID.make("build"),
|
||||||
model: {
|
model: {
|
||||||
@@ -92,6 +96,71 @@ test("provider-executed success retains its raw provider result", async () => {
|
|||||||
expect(success?.data).toHaveProperty("result")
|
expect(success?.data).toHaveProperty("result")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("provider-executed success bounds output before durable publication", async () => {
|
||||||
|
const guarded: ToolOutputStore.BoundInput[] = []
|
||||||
|
const outputStore: Pick<ToolOutputStore.Interface, "bound"> = {
|
||||||
|
bound: (input) =>
|
||||||
|
Effect.sync(() => guarded.push(input)).pipe(
|
||||||
|
Effect.as({
|
||||||
|
output: {
|
||||||
|
structured: { _truncated: true, _bytes: 20_000, _outputPath: "/managed/provider" },
|
||||||
|
content: [{ type: "text" as const, text: "bounded provider output" }],
|
||||||
|
},
|
||||||
|
outputPaths: ["/managed/provider"],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
const { published, publisher } = capture("anthropic", outputStore)
|
||||||
|
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
|
||||||
|
await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
|
||||||
|
|
||||||
|
expect(guarded).toHaveLength(1)
|
||||||
|
expect(published.find((event) => event.type === "session.tool.success.1")?.data).toMatchObject({
|
||||||
|
structured: { _truncated: true, _bytes: 20_000, _outputPath: "/managed/provider" },
|
||||||
|
content: [{ type: "text", text: "bounded provider output" }],
|
||||||
|
result: result.result,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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(
|
||||||
|
|||||||
@@ -3,14 +3,18 @@ import { Tool } from "@opencode-ai/core/tool/tool"
|
|||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import type { PermissionV2 } from "@opencode-ai/core/permission"
|
import type { PermissionV2 } from "@opencode-ai/core/permission"
|
||||||
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 { Global } from "@opencode-ai/core/global"
|
||||||
import { Image } from "@opencode-ai/core/image"
|
import { Image } from "@opencode-ai/core/image"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||||
|
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
|
|
||||||
const bounds: ToolOutputStore.BoundInput[] = []
|
const bounds: ToolOutputStore.BoundInput[] = []
|
||||||
const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
|
const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
|
||||||
@@ -52,6 +56,7 @@ const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [
|
|||||||
[Image.node, imageStore],
|
[Image.node, imageStore],
|
||||||
])
|
])
|
||||||
const it = testEffect(registryLayer)
|
const it = testEffect(registryLayer)
|
||||||
|
const live = testEffect(Layer.empty)
|
||||||
const identity = {
|
const identity = {
|
||||||
agent: AgentV2.ID.make("build"),
|
agent: AgentV2.ID.make("build"),
|
||||||
messageID: SessionMessage.ID.make("msg_registry"),
|
messageID: SessionMessage.ID.make("msg_registry"),
|
||||||
@@ -84,6 +89,129 @@ const constant = (text: string) =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("ToolRegistry", () => {
|
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]()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
live.live("bounds output replaced by an execute-after hook", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) => {
|
||||||
|
const layer = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolHooks.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 hooks = yield* ToolHooks.Service
|
||||||
|
const text = "y".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES)
|
||||||
|
yield* hooks.hook.after((event) => {
|
||||||
|
event.result = { type: "text", value: "hook result" }
|
||||||
|
event.output = { structured: { text }, content: [] }
|
||||||
|
})
|
||||||
|
yield* service.register({ hooked: make() }, { codemode: false })
|
||||||
|
const settled = yield* settleTool(service, {
|
||||||
|
sessionID,
|
||||||
|
...identity,
|
||||||
|
call: { type: "tool-call", id: "call-hooked", name: "hooked", input: { text: "original" } },
|
||||||
|
})
|
||||||
|
const encoded = JSON.stringify({ text })
|
||||||
|
|
||||||
|
expect(settled.result).toEqual({ type: "text", value: "hook result" })
|
||||||
|
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]()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
live.live("allows execute-after hooks to redact output before retention", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) => {
|
||||||
|
const layer = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolHooks.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 hooks = yield* ToolHooks.Service
|
||||||
|
const text = "sensitive".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES)
|
||||||
|
let observed: unknown
|
||||||
|
yield* hooks.hook.after((event) => {
|
||||||
|
observed = event.output?.structured
|
||||||
|
event.output = { structured: { redacted: true }, content: [{ type: "text", text: "redacted" }] }
|
||||||
|
})
|
||||||
|
yield* service.register({ secret: constant(text) }, { codemode: false })
|
||||||
|
const settled = yield* settleTool(service, {
|
||||||
|
sessionID,
|
||||||
|
...identity,
|
||||||
|
call: { type: "tool-call", id: "call-secret", name: "secret", input: { text: "ignored" } },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(observed).toEqual({ text })
|
||||||
|
expect(settled).toEqual({
|
||||||
|
result: { type: "text", value: "redacted" },
|
||||||
|
output: { structured: { redacted: true }, content: [{ type: "text", text: "redacted" }] },
|
||||||
|
})
|
||||||
|
}).pipe(Effect.provide(layer))
|
||||||
|
},
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("rejects invalid dotted namespaces", () =>
|
it.effect("rejects invalid dotted namespaces", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* ToolRegistry.Service
|
const service = yield* ToolRegistry.Service
|
||||||
@@ -113,12 +241,15 @@ describe("ToolRegistry", () => {
|
|||||||
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
|
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* ToolRegistry.Service
|
const service = yield* ToolRegistry.Service
|
||||||
yield* service.register({
|
yield* service.register(
|
||||||
question: make(),
|
{
|
||||||
bash: make(),
|
question: make(),
|
||||||
edit: make("edit"),
|
bash: make(),
|
||||||
write: make("edit"),
|
edit: make("edit"),
|
||||||
}, { codemode: false })
|
write: make("edit"),
|
||||||
|
},
|
||||||
|
{ codemode: false },
|
||||||
|
)
|
||||||
const names = (permissions: PermissionV2.Ruleset) =>
|
const names = (permissions: PermissionV2.Ruleset) =>
|
||||||
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
|
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
|
||||||
|
|
||||||
@@ -191,14 +322,17 @@ describe("ToolRegistry", () => {
|
|||||||
it.effect("returns model errors without swallowing interruption or defects", () =>
|
it.effect("returns model errors without swallowing interruption or defects", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* ToolRegistry.Service
|
const service = yield* ToolRegistry.Service
|
||||||
yield* service.register({
|
yield* service.register(
|
||||||
failed: Tool.make({
|
{
|
||||||
description: "Failed",
|
failed: Tool.make({
|
||||||
input: Schema.Struct({}),
|
description: "Failed",
|
||||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
input: Schema.Struct({}),
|
||||||
execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
|
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||||
}),
|
execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
|
||||||
}, { codemode: false })
|
}),
|
||||||
|
},
|
||||||
|
{ codemode: false },
|
||||||
|
)
|
||||||
expect(
|
expect(
|
||||||
yield* executeTool(service, {
|
yield* executeTool(service, {
|
||||||
sessionID,
|
sessionID,
|
||||||
@@ -214,14 +348,17 @@ describe("ToolRegistry", () => {
|
|||||||
}),
|
}),
|
||||||
).toEqual({ type: "error", value: "Unknown tool: missing" })
|
).toEqual({ type: "error", value: "Unknown tool: missing" })
|
||||||
|
|
||||||
yield* service.register({
|
yield* service.register(
|
||||||
defect: Tool.make({
|
{
|
||||||
description: "Defect",
|
defect: Tool.make({
|
||||||
input: Schema.Struct({}),
|
description: "Defect",
|
||||||
output: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
execute: () => Effect.die("unexpected executor defect"),
|
output: Schema.Struct({}),
|
||||||
}),
|
execute: () => Effect.die("unexpected executor defect"),
|
||||||
}, { codemode: false })
|
}),
|
||||||
|
},
|
||||||
|
{ codemode: false },
|
||||||
|
)
|
||||||
expect(
|
expect(
|
||||||
yield* service.materialize().pipe(
|
yield* service.materialize().pipe(
|
||||||
Effect.flatMap((materialized) =>
|
Effect.flatMap((materialized) =>
|
||||||
@@ -264,22 +401,23 @@ describe("ToolRegistry", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* ToolRegistry.Service
|
const service = yield* ToolRegistry.Service
|
||||||
const contexts: Tool.Context[] = []
|
const contexts: Tool.Context[] = []
|
||||||
yield* service.register({
|
yield* service.register(
|
||||||
context: Tool.make({
|
{
|
||||||
description: "Context",
|
context: Tool.make({
|
||||||
input: Schema.Struct({}),
|
description: "Context",
|
||||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
input: Schema.Struct({}),
|
||||||
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
|
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||||
}),
|
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
|
||||||
}, { codemode: false })
|
}),
|
||||||
|
},
|
||||||
|
{ codemode: false },
|
||||||
|
)
|
||||||
yield* executeTool(service, {
|
yield* executeTool(service, {
|
||||||
sessionID,
|
sessionID,
|
||||||
...identity,
|
...identity,
|
||||||
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
|
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
|
||||||
})
|
})
|
||||||
expect(contexts).toEqual([
|
expect(contexts).toEqual([{ sessionID, ...identity, callID: "call-context", progress: expect.any(Function) }])
|
||||||
{ sessionID, ...identity, callID: "call-context", progress: expect.any(Function) },
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -306,20 +444,23 @@ describe("ToolRegistry", () => {
|
|||||||
it.effect("normalizes image tool output at settlement and drops unresizable images", () =>
|
it.effect("normalizes image tool output at settlement and drops unresizable images", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* ToolRegistry.Service
|
const service = yield* ToolRegistry.Service
|
||||||
yield* service.register({
|
yield* service.register(
|
||||||
snapshot: Tool.make({
|
{
|
||||||
description: "Return images",
|
snapshot: Tool.make({
|
||||||
input: Schema.Struct({ text: Schema.String }),
|
description: "Return images",
|
||||||
output: Schema.Struct({ text: Schema.String }),
|
input: Schema.Struct({ text: Schema.String }),
|
||||||
execute: ({ text }) => Effect.succeed({ text }),
|
output: Schema.Struct({ text: Schema.String }),
|
||||||
toModelOutput: ({ output }) => [
|
execute: ({ text }) => Effect.succeed({ text }),
|
||||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
|
toModelOutput: ({ output }) => [
|
||||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
|
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
|
||||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" },
|
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
|
||||||
{ type: "text", text: output.text },
|
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" },
|
||||||
],
|
{ type: "text", text: output.text },
|
||||||
}),
|
],
|
||||||
}, { codemode: false })
|
}),
|
||||||
|
},
|
||||||
|
{ codemode: false },
|
||||||
|
)
|
||||||
|
|
||||||
const settlement = yield* settleTool(service, call("snapshot"))
|
const settlement = yield* settleTool(service, call("snapshot"))
|
||||||
expect(settlement.output?.content).toEqual([
|
expect(settlement.output?.content).toEqual([
|
||||||
@@ -334,23 +475,26 @@ describe("ToolRegistry", () => {
|
|||||||
it.effect("normalizes image progress content before it is published", () =>
|
it.effect("normalizes image progress content before it is published", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* ToolRegistry.Service
|
const service = yield* ToolRegistry.Service
|
||||||
yield* service.register({
|
yield* service.register(
|
||||||
progressive: Tool.make({
|
{
|
||||||
description: "Emit image progress",
|
progressive: Tool.make({
|
||||||
input: Schema.Struct({ text: Schema.String }),
|
description: "Emit image progress",
|
||||||
output: Schema.Struct({ text: Schema.String }),
|
input: Schema.Struct({ text: Schema.String }),
|
||||||
execute: ({ text }, context) =>
|
output: Schema.Struct({ text: Schema.String }),
|
||||||
context
|
execute: ({ text }, context) =>
|
||||||
.progress({
|
context
|
||||||
structured: { stage: "capture" },
|
.progress({
|
||||||
content: [
|
structured: { stage: "capture" },
|
||||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
|
content: [
|
||||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
|
{ 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 })),
|
})
|
||||||
}),
|
.pipe(Effect.as({ text })),
|
||||||
}, { codemode: false })
|
}),
|
||||||
|
},
|
||||||
|
{ codemode: false },
|
||||||
|
)
|
||||||
|
|
||||||
const updates: ToolRegistry.Progress[] = []
|
const updates: ToolRegistry.Progress[] = []
|
||||||
yield* settleTool(service, {
|
yield* settleTool(service, {
|
||||||
@@ -382,15 +526,18 @@ describe("ToolRegistry", () => {
|
|||||||
encode: SchemaGetter.transform((value) => value === "yes"),
|
encode: SchemaGetter.transform((value) => value === "yes"),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* service.register({
|
yield* service.register(
|
||||||
transformed: Tool.make({
|
{
|
||||||
description: "Transform values",
|
transformed: Tool.make({
|
||||||
input: Schema.Struct({ value: Transformed }),
|
description: "Transform values",
|
||||||
output: Schema.Struct({ value: Transformed }),
|
input: Schema.Struct({ value: Transformed }),
|
||||||
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
|
output: Schema.Struct({ value: Transformed }),
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
|
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
|
||||||
}),
|
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
|
||||||
}, { codemode: false })
|
}),
|
||||||
|
},
|
||||||
|
{ codemode: false },
|
||||||
|
)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
yield* executeTool(service, {
|
yield* executeTool(service, {
|
||||||
@@ -409,25 +556,28 @@ describe("ToolRegistry", () => {
|
|||||||
).toMatchObject({ type: "error", value: expect.stringContaining("Invalid tool input") })
|
).toMatchObject({ type: "error", value: expect.stringContaining("Invalid tool input") })
|
||||||
expect(executed).toEqual(["yes"])
|
expect(executed).toEqual(["yes"])
|
||||||
|
|
||||||
yield* service.register({
|
yield* service.register(
|
||||||
invalid_output: Tool.make({
|
{
|
||||||
description: "Return invalid output",
|
invalid_output: Tool.make({
|
||||||
input: Schema.Struct({}),
|
description: "Return invalid output",
|
||||||
output: Schema.Struct({
|
input: Schema.Struct({}),
|
||||||
value: Schema.Boolean.pipe(
|
output: Schema.Struct({
|
||||||
Schema.decodeTo(Schema.String, {
|
value: Schema.Boolean.pipe(
|
||||||
decode: SchemaGetter.transform((value) => String(value)),
|
Schema.decodeTo(Schema.String, {
|
||||||
encode: SchemaGetter.transformOrFail((value) =>
|
decode: SchemaGetter.transform((value) => String(value)),
|
||||||
value === "valid"
|
encode: SchemaGetter.transformOrFail((value) =>
|
||||||
? Effect.succeed(true)
|
value === "valid"
|
||||||
: Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })),
|
? 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(
|
expect(
|
||||||
yield* executeTool(service, {
|
yield* executeTool(service, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|||||||
@@ -239,43 +239,46 @@ const permission = Layer.succeed(
|
|||||||
)
|
)
|
||||||
const echo = Layer.effectDiscard(
|
const echo = Layer.effectDiscard(
|
||||||
ToolRegistry.Service.use((registry) =>
|
ToolRegistry.Service.use((registry) =>
|
||||||
registry.register({
|
registry.register(
|
||||||
echo: Tool.make({
|
{
|
||||||
description: "Echo text",
|
echo: Tool.make({
|
||||||
input: Schema.Struct({ text: Schema.String }),
|
description: "Echo text",
|
||||||
output: Schema.Struct({ text: Schema.String }),
|
input: Schema.Struct({ text: Schema.String }),
|
||||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
output: Schema.Struct({ text: Schema.String }),
|
||||||
execute: ({ text }, context) =>
|
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
||||||
Effect.gen(function* () {
|
execute: ({ text }, context) =>
|
||||||
authorizations.push(context)
|
Effect.gen(function* () {
|
||||||
executions.push(text)
|
authorizations.push(context)
|
||||||
activeToolExecutions++
|
executions.push(text)
|
||||||
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
|
activeToolExecutions++
|
||||||
if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) {
|
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
|
||||||
yield* Deferred.succeed(toolExecutionsStarted, undefined)
|
if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) {
|
||||||
}
|
yield* Deferred.succeed(toolExecutionsStarted, undefined)
|
||||||
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
|
}
|
||||||
return { text }
|
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
|
||||||
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
|
return { text }
|
||||||
}),
|
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
|
||||||
defect: Tool.make({
|
}),
|
||||||
description: "Fail unexpectedly",
|
defect: Tool.make({
|
||||||
input: Schema.Struct({}),
|
description: "Fail unexpectedly",
|
||||||
output: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
execute: () =>
|
output: Schema.Struct({}),
|
||||||
(toolExecutionGate ? Deferred.await(toolExecutionGate) : Effect.void).pipe(
|
execute: () =>
|
||||||
Effect.andThen(Effect.die("unexpected tool defect")),
|
(toolExecutionGate ? Deferred.await(toolExecutionGate) : Effect.void).pipe(
|
||||||
),
|
Effect.andThen(Effect.die("unexpected tool defect")),
|
||||||
}),
|
),
|
||||||
// BigInt output with no model content forces ToolOutputStore.bound onto its
|
}),
|
||||||
// JSON.stringify encode path, which fails with a typed StorageError.
|
// BigInt output with no model content forces ToolOutputStore.bound onto its
|
||||||
storefail: Tool.make({
|
// JSON.stringify encode path, which fails with a typed StorageError.
|
||||||
description: "Produce output that cannot be persisted",
|
storefail: Tool.make({
|
||||||
input: Schema.Struct({}),
|
description: "Produce output that cannot be persisted",
|
||||||
output: Schema.Any,
|
input: Schema.Struct({}),
|
||||||
execute: () => Effect.succeed({ big: 1n }),
|
output: Schema.Any,
|
||||||
}),
|
execute: () => Effect.succeed({ big: 1n }),
|
||||||
}, { codemode: false }),
|
}),
|
||||||
|
},
|
||||||
|
{ codemode: false },
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
|
const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
|
||||||
@@ -840,19 +843,23 @@ describe("SessionRunnerLLM", () => {
|
|||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
const contexts: Tool.Context[] = []
|
const contexts: Tool.Context[] = []
|
||||||
yield* registry.register({
|
const progress = "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES)
|
||||||
location_context: Tool.make({
|
yield* registry.register(
|
||||||
description: "Read application context",
|
{
|
||||||
input: Schema.Struct({ query: Schema.String }),
|
location_context: Tool.make({
|
||||||
output: Schema.Struct({ answer: Schema.String }),
|
description: "Read application context",
|
||||||
execute: ({ query }, context) =>
|
input: Schema.Struct({ query: Schema.String }),
|
||||||
Effect.gen(function* () {
|
output: Schema.Struct({ answer: Schema.String }),
|
||||||
contexts.push(context)
|
execute: ({ query }, context) =>
|
||||||
yield* context.progress({ structured: { phase: "reading" } })
|
Effect.gen(function* () {
|
||||||
return { answer: query.toUpperCase() }
|
contexts.push(context)
|
||||||
}),
|
yield* context.progress({ structured: { phase: progress } })
|
||||||
}),
|
return { answer: query.toUpperCase() }
|
||||||
}, { codemode: false })
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ codemode: false },
|
||||||
|
)
|
||||||
yield* admit(session, "Use application context")
|
yield* admit(session, "Use application context")
|
||||||
responses = [reply.tool("call-location", "location_context", { query: "hello" }), []]
|
responses = [reply.tool("call-location", "location_context", { query: "hello" }), []]
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
@@ -875,7 +882,13 @@ describe("SessionRunnerLLM", () => {
|
|||||||
progress: expect.any(Function),
|
progress: expect.any(Function),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
expect(Array.from(yield* Fiber.join(progressFiber))[0]?.data.structured).toEqual({ phase: "reading" })
|
const update = Array.from(yield* Fiber.join(progressFiber))[0]?.data
|
||||||
|
expect(update?.structured).toMatchObject({
|
||||||
|
_truncated: true,
|
||||||
|
_bytes: Buffer.byteLength(JSON.stringify({ phase: progress })),
|
||||||
|
_outputPath: expect.any(String),
|
||||||
|
})
|
||||||
|
expect(update?.content).toEqual([{ type: "text", text: JSON.stringify({ phase: progress }) }])
|
||||||
expect(yield* session.context(sessionID)).toMatchObject([
|
expect(yield* session.context(sessionID)).toMatchObject([
|
||||||
{ type: "user", text: "Use application context" },
|
{ type: "user", text: "Use application context" },
|
||||||
{
|
{
|
||||||
@@ -899,14 +912,17 @@ describe("SessionRunnerLLM", () => {
|
|||||||
const scope = yield* Scope.make()
|
const scope = yield* Scope.make()
|
||||||
const executions: string[] = []
|
const executions: string[] = []
|
||||||
yield* registry
|
yield* registry
|
||||||
.register({
|
.register(
|
||||||
reloaded: Tool.make({
|
{
|
||||||
description: "Record the advertised tool",
|
reloaded: Tool.make({
|
||||||
input: Schema.Struct({}),
|
description: "Record the advertised tool",
|
||||||
output: Schema.Struct({ value: Schema.String }),
|
input: Schema.Struct({}),
|
||||||
execute: () => Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ value: "advertised" })),
|
output: Schema.Struct({ value: Schema.String }),
|
||||||
}),
|
execute: () => Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ value: "advertised" })),
|
||||||
}, { codemode: false })
|
}),
|
||||||
|
},
|
||||||
|
{ codemode: false },
|
||||||
|
)
|
||||||
.pipe(Scope.provide(scope))
|
.pipe(Scope.provide(scope))
|
||||||
yield* admit(session, "Use the reloaded tool")
|
yield* admit(session, "Use the reloaded tool")
|
||||||
responses = [
|
responses = [
|
||||||
@@ -924,14 +940,17 @@ describe("SessionRunnerLLM", () => {
|
|||||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||||
yield* Deferred.await(streamStarted)
|
yield* Deferred.await(streamStarted)
|
||||||
yield* Scope.close(scope, Exit.void)
|
yield* Scope.close(scope, Exit.void)
|
||||||
yield* registry.register({
|
yield* registry.register(
|
||||||
reloaded: Tool.make({
|
{
|
||||||
description: "Record the replacement tool",
|
reloaded: Tool.make({
|
||||||
input: Schema.Struct({}),
|
description: "Record the replacement tool",
|
||||||
output: Schema.Struct({ value: Schema.String }),
|
input: Schema.Struct({}),
|
||||||
execute: () => Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ value: "replacement" })),
|
output: Schema.Struct({ value: Schema.String }),
|
||||||
}),
|
execute: () => Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ value: "replacement" })),
|
||||||
}, { codemode: false })
|
}),
|
||||||
|
},
|
||||||
|
{ codemode: false },
|
||||||
|
)
|
||||||
yield* Deferred.succeed(streamGate, undefined)
|
yield* Deferred.succeed(streamGate, undefined)
|
||||||
yield* Fiber.join(run)
|
yield* Fiber.join(run)
|
||||||
|
|
||||||
@@ -3367,17 +3386,20 @@ describe("SessionRunnerLLM", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
yield* registry.register({
|
yield* registry.register(
|
||||||
blocked: Tool.make({
|
{
|
||||||
description: "Fail because policy blocked execution",
|
blocked: Tool.make({
|
||||||
input: Schema.Struct({}),
|
description: "Fail because policy blocked execution",
|
||||||
output: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
execute: () =>
|
output: Schema.Struct({}),
|
||||||
Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "blocked", resources: ["*"] })).pipe(
|
execute: () =>
|
||||||
Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })),
|
Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "blocked", resources: ["*"] })).pipe(
|
||||||
),
|
Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })),
|
||||||
}),
|
),
|
||||||
}, { codemode: false })
|
}),
|
||||||
|
},
|
||||||
|
{ codemode: false },
|
||||||
|
)
|
||||||
yield* admit(session, "Call blocked")
|
yield* admit(session, "Call blocked")
|
||||||
|
|
||||||
responses = [reply.tool("call-blocked", "blocked", {}), reply.stop()]
|
responses = [reply.tool("call-blocked", "blocked", {}), reply.stop()]
|
||||||
@@ -3402,14 +3424,17 @@ describe("SessionRunnerLLM", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
yield* registry.register({
|
yield* registry.register(
|
||||||
declined: Tool.make({
|
{
|
||||||
description: "Fail because the user declined approval",
|
declined: Tool.make({
|
||||||
input: Schema.Struct({}),
|
description: "Fail because the user declined approval",
|
||||||
output: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
execute: () => Effect.die(new PermissionV2.DeclinedError()),
|
output: Schema.Struct({}),
|
||||||
}),
|
execute: () => Effect.die(new PermissionV2.DeclinedError()),
|
||||||
}, { codemode: false })
|
}),
|
||||||
|
},
|
||||||
|
{ codemode: false },
|
||||||
|
)
|
||||||
yield* admit(session, "Call declined")
|
yield* admit(session, "Call declined")
|
||||||
|
|
||||||
response = reply.tool("call-declined", "declined", {})
|
response = reply.tool("call-declined", "declined", {})
|
||||||
@@ -3439,17 +3464,20 @@ describe("SessionRunnerLLM", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
yield* registry.register({
|
yield* registry.register(
|
||||||
corrected: Tool.make({
|
{
|
||||||
description: "Fail with user correction feedback",
|
corrected: Tool.make({
|
||||||
input: Schema.Struct({}),
|
description: "Fail with user correction feedback",
|
||||||
output: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
execute: () =>
|
output: Schema.Struct({}),
|
||||||
Effect.fail(new PermissionV2.CorrectedError({ feedback: "Use another tool" })).pipe(
|
execute: () =>
|
||||||
Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })),
|
Effect.fail(new PermissionV2.CorrectedError({ feedback: "Use another tool" })).pipe(
|
||||||
),
|
Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })),
|
||||||
}),
|
),
|
||||||
}, { codemode: false })
|
}),
|
||||||
|
},
|
||||||
|
{ codemode: false },
|
||||||
|
)
|
||||||
yield* admit(session, "Call corrected")
|
yield* admit(session, "Call corrected")
|
||||||
|
|
||||||
responses = [reply.tool("call-corrected", "corrected", {}), reply.stop()]
|
responses = [reply.tool("call-corrected", "corrected", {}), reply.stop()]
|
||||||
@@ -3505,6 +3533,36 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("does not hide output persistence failure behind another concurrent tool defect", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* setup
|
||||||
|
yield* admit(session, "Call defect and storefail")
|
||||||
|
responses = [
|
||||||
|
[
|
||||||
|
LLMEvent.stepStart({ index: 0 }),
|
||||||
|
LLMEvent.toolCall({ id: "call-defect", name: "defect", input: {} }),
|
||||||
|
LLMEvent.toolCall({ id: "call-storefail", name: "storefail", input: {} }),
|
||||||
|
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||||
|
LLMEvent.finish({ reason: "tool-calls" }),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
]
|
||||||
|
|
||||||
|
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
|
||||||
|
|
||||||
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
|
expect(requests).toHaveLength(1)
|
||||||
|
expect(yield* session.context(sessionID)).toMatchObject([
|
||||||
|
{ type: "user", text: "Call defect and storefail" },
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
finish: "error",
|
||||||
|
error: { type: "unknown", message: expect.stringContaining("Failed to encode tool output") },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("returns configured permission denials to the model and continues", () =>
|
it.effect("returns configured permission denials to the model and continues", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
@@ -3547,14 +3605,17 @@ describe("SessionRunnerLLM", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
yield* registry.register({
|
yield* registry.register(
|
||||||
question: Tool.make({
|
{
|
||||||
description: "Ask the user",
|
question: Tool.make({
|
||||||
input: Schema.Struct({}),
|
description: "Ask the user",
|
||||||
output: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
execute: () => Effect.die(new QuestionTool.CancelledError()),
|
output: Schema.Struct({}),
|
||||||
}),
|
execute: () => Effect.die(new QuestionTool.CancelledError()),
|
||||||
}, { codemode: false })
|
}),
|
||||||
|
},
|
||||||
|
{ codemode: false },
|
||||||
|
)
|
||||||
yield* admit(session, "Ask then stop")
|
yield* admit(session, "Ask then stop")
|
||||||
|
|
||||||
responses = [reply.tool("call-question", "question", {}), []]
|
responses = [reply.tool("call-question", "question", {}), []]
|
||||||
|
|||||||
@@ -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,48 @@ 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(20_000)
|
||||||
|
const after = "y".repeat(20_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,
|
||||||
|
)
|
||||||
|
const hunk = parsePatch(structured.files[0]?.patch ?? "")[0]?.hunks[0]
|
||||||
|
expect(hunk?.lines.some((line) => line.startsWith("-"))).toBe(true)
|
||||||
|
expect(hunk?.lines.some((line) => line.startsWith("+"))).toBe(true)
|
||||||
|
expect(hunk).toMatchObject({ oldLines: 1, newLines: 1 })
|
||||||
|
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()),
|
||||||
|
|||||||
@@ -93,3 +93,70 @@ test("execute supports callable namespace tools", async () => {
|
|||||||
})
|
})
|
||||||
expect(result.content).toEqual([{ type: "text", text: '[\n "admin",\n "created"\n]' }])
|
expect(result.content).toEqual([{ type: "text", text: '[\n "admin",\n "created"\n]' }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("execute exposes typed internal output instead of the persisted receipt", 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 }),
|
||||||
|
codeModeOutput: "output",
|
||||||
|
toStructuredOutput: ({ output }) => ({ bytes: output.bytes }),
|
||||||
|
toModelOutput: ({ output }) => [{ type: "text", text: output.content }],
|
||||||
|
execute: () => Effect.succeed({ content: "full payload", bytes: 12 }),
|
||||||
|
})
|
||||||
|
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({})).content" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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.structured).toEqual({ toolCalls: [{ tool: "fetch", status: "completed" }] })
|
||||||
|
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}' }])
|
||||||
|
})
|
||||||
|
|||||||
@@ -75,7 +75,14 @@ describe("ToolOutputStore", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) }
|
const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) }
|
||||||
const result = yield* store.bound({ sessionID, callID: "call-json", output: { structured, content: [] } })
|
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(Buffer.byteLength(JSON.stringify(result.output.structured))).toBeLessThanOrEqual(
|
||||||
|
ToolOutputStore.MAX_STRUCTURED_BYTES,
|
||||||
|
)
|
||||||
expect(result.outputPaths).toHaveLength(1)
|
expect(result.outputPaths).toHaveLength(1)
|
||||||
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
|
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
|
||||||
expect(result.output.content).toHaveLength(1)
|
expect(result.output.content).toHaveLength(1)
|
||||||
@@ -83,6 +90,131 @@ 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("measures the structured boundary in UTF-8 bytes", () =>
|
||||||
|
withStore(({ store }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const structured = { text: "é".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES / 2) }
|
||||||
|
const encoded = JSON.stringify(structured)
|
||||||
|
expect(structured.text.length).toBeLessThan(ToolOutputStore.MAX_STRUCTURED_BYTES)
|
||||||
|
expect(Buffer.byteLength(encoded)).toBeGreaterThan(ToolOutputStore.MAX_STRUCTURED_BYTES)
|
||||||
|
|
||||||
|
const result = yield* store.bound({
|
||||||
|
sessionID,
|
||||||
|
callID: "call-structured-utf8",
|
||||||
|
output: { structured, content: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.output.structured).toMatchObject({
|
||||||
|
_truncated: true,
|
||||||
|
_bytes: Buffer.byteLength(encoded),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("retains independent structured and contextual overflow", () =>
|
||||||
|
withStore(({ store, fs }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const structured = { detail: "s".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES) }
|
||||||
|
const content = "c".repeat(ToolOutputStore.MAX_BYTES + 1)
|
||||||
|
const result = yield* store.bound({
|
||||||
|
sessionID,
|
||||||
|
callID: "call-two-channel-overflow",
|
||||||
|
output: { structured, content: [{ type: "text", text: content }] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.outputPaths).toHaveLength(2)
|
||||||
|
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
|
||||||
|
expect(yield* fs.readFileString(result.outputPaths[1])).toBe(content)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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("normalizes structured values to their durable JSON record", () =>
|
||||||
|
withStore(({ store }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const primitive = yield* store.bound({
|
||||||
|
sessionID,
|
||||||
|
callID: "call-primitive",
|
||||||
|
output: { structured: "value", content: [] },
|
||||||
|
})
|
||||||
|
const nonFinite = yield* store.bound({
|
||||||
|
sessionID,
|
||||||
|
callID: "call-non-finite",
|
||||||
|
output: { structured: { value: Number.NaN }, content: [] },
|
||||||
|
})
|
||||||
|
const omitted = yield* store.bound({
|
||||||
|
sessionID,
|
||||||
|
callID: "call-omitted",
|
||||||
|
output: { structured: undefined, content: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(primitive.output.structured).toEqual({ value: "value" })
|
||||||
|
expect(nonFinite.output.structured).toEqual({ value: null })
|
||||||
|
expect(omitted.output.structured).toEqual({})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("measures primitive overflow after durable record normalization", () =>
|
||||||
|
withStore(({ store, fs }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const value = "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES)
|
||||||
|
const encoded = JSON.stringify({ value })
|
||||||
|
const result = yield* store.bound({
|
||||||
|
sessionID,
|
||||||
|
callID: "call-primitive-overflow",
|
||||||
|
output: { structured: value, content: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.output.structured).toEqual({
|
||||||
|
_truncated: true,
|
||||||
|
_bytes: Buffer.byteLength(encoded),
|
||||||
|
_outputPath: result.outputPaths[0],
|
||||||
|
})
|
||||||
|
expect(yield* fs.readFileString(result.outputPaths[0])).toBe(encoded)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("preserves native media and structured metadata without applying a settlement media limit", () =>
|
it.live("preserves native media and structured metadata without applying a settlement media limit", () =>
|
||||||
withStore(({ store }) =>
|
withStore(({ store }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -131,15 +263,52 @@ describe("ToolOutputStore", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("does not double-count structured data duplicated in projected text", () =>
|
it.live("marks projected receipt metadata truncated when bounding its contextual output", () =>
|
||||||
withStore(({ store }) =>
|
withStore(({ store }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const result = yield* store.bound({
|
||||||
|
sessionID,
|
||||||
|
callID: "call-receipt",
|
||||||
|
propagateTruncation: true,
|
||||||
|
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("preserves ordinary truncated metadata when bounding contextual output", () =>
|
||||||
|
withStore(({ store }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const result = yield* store.bound({
|
||||||
|
sessionID,
|
||||||
|
callID: "call-ordinary-truncated",
|
||||||
|
output: {
|
||||||
|
structured: { truncated: false },
|
||||||
|
content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(result.output.structured).toEqual({ truncated: false })
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("bounds structured data duplicated in projected text independently", () =>
|
||||||
|
withStore(({ store, fs }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const text = "x".repeat(30_000)
|
const text = "x".repeat(30_000)
|
||||||
const output = { structured: { output: text }, content: [{ type: "text" as const, text }] }
|
const output = { structured: { output: text }, content: [{ type: "text" as const, text }] }
|
||||||
expect(yield* store.bound({ sessionID, callID: "call-duplicated", output })).toEqual({
|
const result = yield* store.bound({ sessionID, callID: "call-duplicated", output })
|
||||||
output,
|
expect(result.output.content).toEqual(output.content)
|
||||||
outputPaths: [],
|
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 +331,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 }) =>
|
withStore(({ store }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const output = { structured: { value: 1n }, content: [{ type: "text" as const, text: "readable text" }] }
|
const output = { structured: { value: 1n }, content: [{ type: "text" as const, text: "readable text" }] }
|
||||||
expect(yield* store.bound({ sessionID, callID: "call-unencodable", output })).toEqual({
|
const exit = yield* store.bound({ sessionID, callID: "call-unencodable", output }).pipe(Effect.exit)
|
||||||
output,
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
outputPaths: [],
|
if (Exit.isFailure(exit))
|
||||||
})
|
expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({
|
||||||
|
_tag: "ToolOutputStore.StorageError",
|
||||||
|
operation: "encode",
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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, test } from "bun:test"
|
||||||
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
import { Deferred, Effect, Exit, Fiber, Layer, Schema } from "effect"
|
||||||
|
import { createTwoFilesPatch, 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"
|
||||||
@@ -14,6 +15,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||||
import { PatchTool } from "@opencode-ai/core/tool/patch"
|
import { PatchTool } from "@opencode-ai/core/tool/patch"
|
||||||
|
import { ToolStructured } from "@opencode-ai/core/tool/structured"
|
||||||
import { location } from "./fixture/location"
|
import { location } from "./fixture/location"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||||
@@ -145,6 +147,22 @@ const exists = (target: string) =>
|
|||||||
const it = testEffect(Layer.empty)
|
const it = testEffect(Layer.empty)
|
||||||
|
|
||||||
describe("PatchTool", () => {
|
describe("PatchTool", () => {
|
||||||
|
test("summarizes multi-line truncation without fabricating unchanged context", () => {
|
||||||
|
const before = Array.from({ length: 100 }, (_, index) => `old-${index}`).join("\n")
|
||||||
|
const after = Array.from({ length: 100 }, (_, index) => `new-${index}`).join("\n")
|
||||||
|
const summary = ToolStructured.patch(createTwoFilesPatch("large.txt", "large.txt", before, after), 500)
|
||||||
|
const hunk = parsePatch(summary)[0]?.hunks[0]
|
||||||
|
|
||||||
|
expect(hunk?.lines.some((line) => line.startsWith(" "))).toBe(false)
|
||||||
|
expect(hunk?.lines).toEqual([
|
||||||
|
"-old-0",
|
||||||
|
"+new-0",
|
||||||
|
"-... 99 removed lines omitted ...",
|
||||||
|
"+... 99 added lines omitted ...",
|
||||||
|
])
|
||||||
|
expect(hunk).toMatchObject({ oldLines: 2, newLines: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
it.live("registers and sequentially applies add, update, and delete hunks", () =>
|
it.live("registers and sequentially applies add, update, and delete hunks", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
@@ -217,6 +235,43 @@ 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(20_000)
|
||||||
|
const after = "y".repeat(20_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,
|
||||||
|
)
|
||||||
|
const hunk = parsePatch(structured.files[0]?.patch ?? "")[0]?.hunks[0]
|
||||||
|
expect(hunk?.lines.some((line) => line.startsWith("-"))).toBe(true)
|
||||||
|
expect(hunk?.lines.some((line) => line.startsWith("+"))).toBe(true)
|
||||||
|
expect(hunk).toMatchObject({ oldLines: 1, newLines: 1 })
|
||||||
|
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
|
||||||
|
|||||||
@@ -100,6 +100,21 @@ describe("ReadToolFileSystem", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("reports a shortened long line as truncated", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { fs, files, directory } = yield* fixture
|
||||||
|
const file = path.join(directory, "long-line.txt")
|
||||||
|
yield* files.writeFileString(file, "x".repeat(3_000))
|
||||||
|
|
||||||
|
const result = yield* ReadToolFileSystem.read(fs, file, "long-line.txt", { limit: 1 })
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ type: "text-page", truncated: true })
|
||||||
|
if (!(result instanceof ReadToolFileSystem.TextPage)) throw new Error("expected text page")
|
||||||
|
expect(result.next).toBeUndefined()
|
||||||
|
expect(result.content).toEndWith("... (line truncated to 2000 chars)")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("preserves the media ingestion limit message", () =>
|
it.effect("preserves the media ingestion limit message", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { fs, files, directory } = yield* fixture
|
const { fs, files, directory } = yield* fixture
|
||||||
|
|||||||
@@ -206,14 +206,14 @@ describe("ReadTool", () => {
|
|||||||
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
|
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
type: "json",
|
type: "text",
|
||||||
value: {
|
value: JSON.stringify({
|
||||||
uri: "file:///README.md",
|
uri: "file:///README.md",
|
||||||
name: "README.md",
|
name: "README.md",
|
||||||
content: "hello",
|
content: "hello",
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
mime: "text/plain",
|
mime: "text/plain",
|
||||||
},
|
}),
|
||||||
})
|
})
|
||||||
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }])
|
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }])
|
||||||
expect(readCalls).toEqual([
|
expect(readCalls).toEqual([
|
||||||
@@ -236,7 +236,7 @@ describe("ReadTool", () => {
|
|||||||
...toolIdentity,
|
...toolIdentity,
|
||||||
call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } },
|
call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } },
|
||||||
}),
|
}),
|
||||||
).toMatchObject({ type: "json" })
|
).toMatchObject({ type: "text" })
|
||||||
expect(assertions).toMatchObject([
|
expect(assertions).toMatchObject([
|
||||||
{
|
{
|
||||||
sessionID,
|
sessionID,
|
||||||
@@ -287,14 +287,15 @@ describe("ReadTool", () => {
|
|||||||
call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
|
call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
|
||||||
})
|
})
|
||||||
expect(settled.output?.structured).toMatchObject({
|
expect(settled.output?.structured).toMatchObject({
|
||||||
|
type: "file",
|
||||||
uri: "file:///pixel.png",
|
uri: "file:///pixel.png",
|
||||||
name: "pixel.png",
|
name: "pixel.png",
|
||||||
mime: "image/png",
|
mime: "image/png",
|
||||||
encoding: "base64",
|
encoding: "base64",
|
||||||
// Image base64 is carried by the content file item only; structured is slimmed
|
bytes: Buffer.byteLength(png, "base64"),
|
||||||
// so the original bytes are never persisted twice.
|
truncated: false,
|
||||||
content: "",
|
|
||||||
})
|
})
|
||||||
|
expect(settled.output?.structured).not.toHaveProperty("content")
|
||||||
expect(settled.output?.content).toMatchObject([
|
expect(settled.output?.content).toMatchObject([
|
||||||
{ type: "text", text: "Image read successfully" },
|
{ type: "text", text: "Image read successfully" },
|
||||||
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
|
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
|
||||||
@@ -626,7 +627,7 @@ describe("ReadTool", () => {
|
|||||||
input: { path: "src", offset: 2, limit: 10 },
|
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(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
|
||||||
expect(listCalls).toEqual([{ offset: 2, limit: 10 }])
|
expect(listCalls).toEqual([{ offset: 2, limit: 10 }])
|
||||||
}),
|
}),
|
||||||
@@ -692,8 +693,15 @@ describe("ReadTool", () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
type: "json",
|
type: "text",
|
||||||
value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
|
value: JSON.stringify({
|
||||||
|
type: "text-page",
|
||||||
|
content: "hello",
|
||||||
|
mime: "text/plain",
|
||||||
|
offset: 2,
|
||||||
|
truncated: true,
|
||||||
|
next: 3,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
expect(readCalls).toEqual([
|
expect(readCalls).toEqual([
|
||||||
{ input: AbsolutePath.make(path.join(process.cwd(), "large.txt")), page: { offset: 2, limit: 1 } },
|
{ input: AbsolutePath.make(path.join(process.cwd(), "large.txt")), page: { offset: 2, limit: 1 } },
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ describe("search tools", () => {
|
|||||||
const glob = yield* settleTool(registry, call("glob", { pattern: "*" }))
|
const glob = yield* settleTool(registry, call("glob", { pattern: "*" }))
|
||||||
const grep = yield* settleTool(registry, call("grep", { pattern: "needle" }))
|
const grep = yield* settleTool(registry, call("grep", { pattern: "needle" }))
|
||||||
|
|
||||||
expect(glob.output?.structured).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
|
expect(glob.output?.structured).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT })
|
||||||
expect(grep.output?.structured).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
|
expect(grep.output?.structured).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -223,6 +223,30 @@ describe("ShellTool", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("marks output truncated when generic settlement limits are lower than shell capture limits", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) => {
|
||||||
|
reset()
|
||||||
|
return withSession(tmp.path, (registry) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const settled = yield* settleTool(
|
||||||
|
registry,
|
||||||
|
call({ command: overflowCommand(ToolOutputStore.MAX_BYTES + 1_000) }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: true })
|
||||||
|
expect(settled.output?.content[0]).toMatchObject({
|
||||||
|
type: "text",
|
||||||
|
text: expect.stringContaining("output truncated; full content saved to"),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("resolves a relative workdir from the active Location", () =>
|
it.live("resolves a relative workdir from the active Location", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
@@ -439,7 +463,10 @@ describe("ShellTool", () => {
|
|||||||
if (update.structured.truncated !== true) return
|
if (update.structured.truncated !== true) return
|
||||||
const content = update.content[0]
|
const content = update.content[0]
|
||||||
if (content?.type !== "text") return
|
if (content?.type !== "text") return
|
||||||
if (content.text.indexOf("\n\n[output truncated; full output saved to:") !== ShellTool.MAX_CAPTURE_BYTES)
|
if (
|
||||||
|
content.text.indexOf("\n\n[output truncated; full output saved to:") !==
|
||||||
|
ShellTool.MAX_CAPTURE_BYTES
|
||||||
|
)
|
||||||
return
|
return
|
||||||
yield* Deferred.succeed(observed, update)
|
yield* Deferred.succeed(observed, update)
|
||||||
yield* Effect.promise(() => fs.writeFile(releasePath, ""))
|
yield* Effect.promise(() => fs.writeFile(releasePath, ""))
|
||||||
|
|||||||
@@ -121,7 +121,14 @@ describe("SkillTool", () => {
|
|||||||
}),
|
}),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) },
|
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([
|
expect(assertions).toMatchObject([
|
||||||
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
|
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID:
|
|||||||
const parentModel = ModelV2.Ref.make({ id: ModelV2.ID.make("parent"), providerID: ProviderV2.ID.make("test") })
|
const parentModel = ModelV2.Ref.make({ id: ModelV2.ID.make("parent"), providerID: ProviderV2.ID.make("test") })
|
||||||
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||||
|
|
||||||
const outputSessionID = (value: unknown) => Schema.decodeUnknownSync(SubagentTool.Output)(value).sessionID
|
const outputSessionID = (value: unknown) =>
|
||||||
|
Schema.decodeUnknownSync(Schema.Struct({ sessionID: SessionV2.ID }))(value).sessionID
|
||||||
|
|
||||||
const executionNode = makeGlobalNode({
|
const executionNode = makeGlobalNode({
|
||||||
service: SessionExecution.Service,
|
service: SessionExecution.Service,
|
||||||
@@ -229,7 +230,12 @@ describe("SubagentTool", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText })
|
expect(settled.output?.structured).toMatchObject({
|
||||||
|
status: "completed",
|
||||||
|
bytes: Buffer.byteLength(childText),
|
||||||
|
truncated: false,
|
||||||
|
})
|
||||||
|
expect(settled.output?.structured).not.toHaveProperty("output")
|
||||||
expect((yield* sessions.get(outputSessionID(settled.output?.structured))).parentID).toBe(parent.id)
|
expect((yield* sessions.get(outputSessionID(settled.output?.structured))).parentID).toBe(parent.id)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -264,7 +270,13 @@ describe("SubagentTool", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText })
|
expect(settled.result).toEqual({ type: "text", value: childText })
|
||||||
|
expect(settled.output?.structured).toMatchObject({
|
||||||
|
status: "completed",
|
||||||
|
bytes: Buffer.byteLength(childText),
|
||||||
|
truncated: false,
|
||||||
|
})
|
||||||
|
expect(settled.output?.structured).not.toHaveProperty("output")
|
||||||
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
|
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
|
||||||
expect(progress[0]?.structured).toEqual({ sessionID: child.id, status: "running" })
|
expect(progress[0]?.structured).toEqual({ sessionID: child.id, status: "running" })
|
||||||
expect(child).toMatchObject({
|
expect(child).toMatchObject({
|
||||||
@@ -358,8 +370,10 @@ describe("SubagentTool", () => {
|
|||||||
const childID = outputSessionID(settled.output?.structured)
|
const childID = outputSessionID(settled.output?.structured)
|
||||||
expect(settled.output?.structured).toMatchObject({
|
expect(settled.output?.structured).toMatchObject({
|
||||||
status: "running",
|
status: "running",
|
||||||
output: expect.stringContaining(`id: ${childID}`),
|
bytes: expect.any(Number),
|
||||||
|
truncated: false,
|
||||||
})
|
})
|
||||||
|
expect(settled.output?.structured).not.toHaveProperty("output")
|
||||||
|
|
||||||
const admission = Array.from(yield* Fiber.join(admitted))[0]
|
const admission = Array.from(yield* Fiber.join(admitted))[0]
|
||||||
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
|
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ describe("WebFetchTool registration", () => {
|
|||||||
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
|
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
|
||||||
result: { type: "text", value: "hello" },
|
result: { type: "text", value: "hello" },
|
||||||
output: {
|
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" }],
|
content: [{ type: "text", text: "hello" }],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ describe("WebSearchTool registration", () => {
|
|||||||
expect(settled).toEqual({
|
expect(settled).toEqual({
|
||||||
result: { type: "text", value: "parallel results" },
|
result: { type: "text", value: "parallel results" },
|
||||||
output: {
|
output: {
|
||||||
structured: { provider: "parallel", text: "parallel results" },
|
structured: { provider: "parallel", bytes: 16, truncated: false },
|
||||||
content: [{ type: "text", text: "parallel results" }],
|
content: [{ type: "text", text: "parallel results" }],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user