Skip to content

Commit 4d6a67f

Browse files
committed
fix(opencode): bound oversized tool errors
1 parent 1ff4926 commit 4d6a67f

6 files changed

Lines changed: 207 additions & 9 deletions

File tree

packages/opencode/src/session/llm.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,37 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer"
2929
import { LLMAISDK } from "./llm/ai-sdk"
3030
import { LLMNativeRuntime } from "./llm/native-runtime"
3131
import { LLMRequestPrep } from "./llm/request"
32+
import { Truncate } from "@/tool/truncate"
3233

3334
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
3435

36+
const TOOL_CALL_ERROR_MAX_CHARS = Truncate.MAX_ERROR_CHARS
37+
const TOOL_CALL_ERROR_CONTEXT_CHARS = Math.floor(TOOL_CALL_ERROR_MAX_CHARS / 2)
38+
const TOOL_CALL_NAME_MAX_CHARS = 256
39+
40+
export function invalidToolCallInput(tool: string, message: string) {
41+
const exact = JSON.stringify({ tool, error: message })
42+
if (exact.length <= TOOL_CALL_ERROR_MAX_CHARS) return exact
43+
44+
const name = tool.length <= TOOL_CALL_NAME_MAX_CHARS ? tool : `${tool.slice(0, TOOL_CALL_NAME_MAX_CHARS - 3)}...`
45+
const maximum = Math.min(TOOL_CALL_ERROR_CONTEXT_CHARS, Math.floor(message.length / 2))
46+
let lower = 0
47+
let upper = maximum
48+
let result = JSON.stringify({ tool: name, error: `... ${message.length} characters omitted ...` })
49+
while (lower <= upper) {
50+
const context = Math.floor((lower + upper) / 2)
51+
const error = `${message.slice(0, context)}\n... ${message.length - context * 2} characters omitted ...\n${context === 0 ? "" : message.slice(-context)}`
52+
const candidate = JSON.stringify({ tool: name, error })
53+
if (candidate.length > TOOL_CALL_ERROR_MAX_CHARS) {
54+
upper = context - 1
55+
continue
56+
}
57+
result = candidate
58+
lower = context + 1
59+
}
60+
return result
61+
}
62+
3563
export type StreamInput = {
3664
user: SessionV1.User
3765
sessionID: string
@@ -70,6 +98,7 @@ const live: Layer.Layer<
7098
| EventV2Bridge.Service
7199
| LLMClientService
72100
| RuntimeFlags.Service
101+
| Truncate.Service
73102
> = Layer.effect(
74103
Service,
75104
Effect.gen(function* () {
@@ -81,6 +110,7 @@ const live: Layer.Layer<
81110
const events = yield* EventV2Bridge.Service
82111
const llmClient = yield* LLMClient.Service
83112
const flags = yield* RuntimeFlags.Service
113+
const truncate = yield* Truncate.Service
84114

85115
const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
86116
yield* Effect.logInfo("stream", {
@@ -142,7 +172,8 @@ const live: Layer.Layer<
142172
title: typeof result === "object" ? result?.title : undefined,
143173
}
144174
} catch (e: any) {
145-
return { result: "", error: e.message ?? String(e) }
175+
const error = await bridge.promise(truncate.error(e.message ?? String(e)))
176+
return { result: "", error: error.content }
146177
}
147178
}
148179

@@ -301,12 +332,10 @@ const live: Layer.Layer<
301332
toolName: lower,
302333
}
303334
}
335+
const error = await bridge.promise(truncate.error(failed.error.message))
304336
return {
305337
...failed.toolCall,
306-
input: JSON.stringify({
307-
tool: failed.toolCall.toolName,
308-
error: failed.error.message,
309-
}),
338+
input: invalidToolCallInput(failed.toolCall.toolName, error.content),
310339
toolName: "invalid",
311340
}
312341
},
@@ -398,6 +427,7 @@ export const node = LayerNode.make({
398427
EventV2Bridge.node,
399428
llmClient,
400429
RuntimeFlags.node,
430+
Truncate.node,
401431
],
402432
})
403433

packages/opencode/src/session/session.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
4444
import { ProviderV2 } from "@opencode-ai/core/provider"
4545
import { ModelV2 } from "@opencode-ai/core/model"
4646
import { SessionMessage } from "@opencode-ai/schema/session-message"
47+
import { Truncate } from "@/tool/truncate"
4748

4849
const parentTitlePrefix = "New session - "
4950
const childTitlePrefix = "Child session - "
@@ -488,7 +489,7 @@ export type Patch = Omit<Partial<Info>, "time" | "share" | "summary" | "revert"
488489
const layer: Layer.Layer<
489490
Service,
490491
never,
491-
BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service
492+
BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service | Truncate.Service
492493
> = Layer.effect(
493494
Service,
494495
Effect.gen(function* () {
@@ -497,6 +498,7 @@ const layer: Layer.Layer<
497498
const background = yield* BackgroundJob.Service
498499
const events = yield* EventV2Bridge.Service
499500
const flags = yield* RuntimeFlags.Service
501+
const truncate = yield* Truncate.Service
500502

501503
const createNext = Effect.fn("Session.createNext")(function* (input: {
502504
id?: SessionID
@@ -636,6 +638,10 @@ const layer: Layer.Layer<
636638

637639
const updatePart = <T extends SessionV1.Part>(part: T): Effect.Effect<T> =>
638640
Effect.gen(function* () {
641+
if (part.type === "tool" && part.state.status === "error") {
642+
const bounded = yield* truncate.error(part.state.error)
643+
part.state.error = bounded.content
644+
}
639645
yield* events.publish(SessionV1.Event.PartUpdated, {
640646
sessionID: part.sessionID,
641647
part: structuredClone(part),
@@ -1012,7 +1018,7 @@ function listByProject(
10121018
export const node = LayerNode.make({
10131019
service: Service,
10141020
layer: layer,
1015-
deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node],
1021+
deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node, Truncate.node],
10161022
})
10171023

10181024
export * as Session from "./session"

packages/opencode/src/tool/truncate.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const RETENTION = Duration.days(7)
1414

1515
export const MAX_LINES = 2000
1616
export const MAX_BYTES = 50 * 1024
17+
export const MAX_ERROR_CHARS = 10_000
1718
export const DIR = TRUNCATION_DIR
1819
export const GLOB = path.join(TRUNCATION_DIR, "*")
1920

@@ -33,6 +34,11 @@ function hasTaskTool(agent?: Agent.Info) {
3334
export interface Interface {
3435
readonly cleanup: () => Effect.Effect<void>
3536
readonly write: (text: string) => Effect.Effect<string>
37+
/**
38+
* Keeps short tool errors unchanged. Larger errors are written to the same
39+
* retained output store and replaced with a bounded head/tail preview.
40+
*/
41+
readonly error: (text: string) => Effect.Effect<Result>
3642
/**
3743
* Returns output unchanged when it fits within the limits, otherwise writes the full text
3844
* to the truncation directory and returns a preview plus a hint to inspect the saved file.
@@ -72,6 +78,21 @@ const layer = Layer.effect(
7278
return file
7379
})
7480

81+
const error = Effect.fn("Truncate.error")(function* (text: string) {
82+
if (text.length <= MAX_ERROR_CHARS) return { content: text, truncated: false } as const
83+
84+
const file = yield* write(text)
85+
const notice = (omitted: number) =>
86+
`\n\n...${omitted} characters truncated...\n\nThe tool call failed and the full error was saved to: ${file}\nUse Grep to search the full error or Read with offset/limit to inspect specific sections.\n\n`
87+
const context = Math.max(0, Math.floor((MAX_ERROR_CHARS - notice(text.length).length) / 2))
88+
const omitted = text.length - context * 2
89+
return {
90+
content: `${text.slice(0, context)}${notice(omitted)}${context === 0 ? "" : text.slice(-context)}`,
91+
truncated: true,
92+
outputPath: file,
93+
} as const
94+
})
95+
7596
const limits = Effect.fn("Truncate.limits")(function* () {
7697
const configSvc = yield* Effect.serviceOption(Config.Service)
7798
if (Option.isNone(configSvc)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES }
@@ -147,7 +168,7 @@ const layer = Layer.effect(
147168
Effect.forkScoped,
148169
)
149170

150-
return Service.of({ cleanup, write, output, limits })
171+
return Service.of({ cleanup, write, error, output, limits })
151172
}),
152173
)
153174

packages/opencode/test/session/llm.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
33
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
44
import { SessionV1 } from "@opencode-ai/core/v1/session"
55
import path from "path"
6-
import { tool, type ModelMessage } from "ai"
6+
import { InvalidToolInputError, tool, type ModelMessage } from "ai"
7+
import { JSONParseError } from "@ai-sdk/provider"
78
import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"
89
import { InstanceRef } from "../../src/effect/instance-ref"
910
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
@@ -27,6 +28,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
2728
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
2829
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
2930
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
31+
import { Truncate } from "@/tool/truncate"
3032

3133
type ConfigModel = NonNullable<NonNullable<ConfigV1.Info["provider"]>[string]["models"]>[string]
3234

@@ -172,6 +174,67 @@ describe("session.llm.hasToolCalls", () => {
172174
})
173175
})
174176

177+
describe("session.llm.invalidToolCallInput", () => {
178+
test("bounds JSON parsing errors stored in repaired tool calls", () => {
179+
const dropped = "GIANT_MIDDLE_SHOULD_NOT_BE_STORED"
180+
const malformed = `{"query":"USEFUL_PREFIX:${"x".repeat(100_000)}${dropped}${"y".repeat(100_000)}:USEFUL_INPUT_TAIL`
181+
expect(malformed.length).toBeGreaterThan(200_000)
182+
183+
let cause: unknown
184+
try {
185+
JSON.parse(malformed)
186+
} catch (error) {
187+
cause = error
188+
}
189+
if (!cause) throw new Error("expected malformed input to fail JSON parsing")
190+
191+
const failed = new InvalidToolInputError({
192+
toolName: "lookup",
193+
toolInput: malformed,
194+
cause: new JSONParseError({ text: malformed, cause }),
195+
})
196+
expect(failed.message.length).toBeGreaterThan(200_000)
197+
198+
const storedSchema = z.object({ tool: z.string(), error: z.string() })
199+
const stored = LLM.invalidToolCallInput("lookup", failed.message)
200+
const repaired = storedSchema.parse(JSON.parse(stored))
201+
const omission = repaired.error.match(/\n\.\.\. (\d+) characters omitted \.\.\.\n/)
202+
203+
expect(stored.length).toBeLessThanOrEqual(Truncate.MAX_ERROR_CHARS)
204+
expect(repaired.error.length).toBeLessThanOrEqual(Truncate.MAX_ERROR_CHARS)
205+
expect(repaired.tool).toBe("lookup")
206+
expect(repaired.error).toContain("USEFUL_PREFIX")
207+
expect(repaired.error).toContain("USEFUL_INPUT_TAIL")
208+
expect(repaired.error).toContain("JSON Parse error: Unterminated string")
209+
expect(omission).not.toBeNull()
210+
if (!omission) throw new Error("expected repaired error to report omitted characters")
211+
expect(Number(omission[1])).toBe(failed.message.length - (repaired.error.length - omission[0].length))
212+
expect(repaired.error).not.toContain(dropped)
213+
214+
const short = "short tool error with exact whitespace\n"
215+
const unchanged = storedSchema.parse(JSON.parse(LLM.invalidToolCallInput("lookup", short)))
216+
expect(unchanged.error).toBe(short)
217+
218+
const whitespaceMalformed =
219+
'{"description":"Test resumed player extraction","code":"const x=1;' + "\n\t".repeat(118_000) + '"}'
220+
let whitespaceCause: unknown
221+
try {
222+
JSON.parse(whitespaceMalformed)
223+
} catch (error) {
224+
whitespaceCause = error
225+
}
226+
if (!whitespaceCause) throw new Error("expected whitespace-heavy input to fail JSON parsing")
227+
const whitespaceError = new InvalidToolInputError({
228+
toolName: "browser_execute",
229+
toolInput: whitespaceMalformed,
230+
cause: new JSONParseError({ text: whitespaceMalformed, cause: whitespaceCause }),
231+
})
232+
const boundedWhitespace = LLM.invalidToolCallInput("browser_execute", whitespaceError.message)
233+
expect(boundedWhitespace.length).toBeLessThanOrEqual(Truncate.MAX_ERROR_CHARS)
234+
expect(JSON.parse(boundedWhitespace).error).toContain("characters omitted")
235+
})
236+
})
237+
175238
describe("session.llm.ai-sdk adapter", () => {
176239
type AISDKAdapterEvent = Parameters<typeof LLMAISDK.toLLMEvents>[1]
177240

packages/opencode/test/session/session.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
1616
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
1717
import { InstanceStore } from "@/project/instance-store"
1818
import { InstanceBootstrap } from "@/project/bootstrap"
19+
import { Truncate } from "@/tool/truncate"
1920

2021
const it = testEffect(
2122
AppNodeBuilder.build(
@@ -206,6 +207,51 @@ describe("step-finish token propagation via event", () => {
206207
})
207208

208209
describe("Session", () => {
210+
it.instance("bounds every persisted tool error and saves the full text", () =>
211+
Effect.gen(function* () {
212+
const session = yield* SessionNs.Service
213+
const info = yield* Effect.acquireRelease(session.create({ title: "tool-error" }), (created) =>
214+
session.remove(created.id).pipe(Effect.ignore),
215+
)
216+
const messageID = MessageID.ascending()
217+
yield* session.updateMessage({
218+
id: messageID,
219+
sessionID: info.id,
220+
role: "user",
221+
time: { created: Date.now() },
222+
agent: "build",
223+
model: { providerID: "test", modelID: "test" },
224+
} as unknown as SessionV1.Info)
225+
226+
const error = `ERROR_HEAD:${"e".repeat(15_000)}GIANT_MIDDLE${"r".repeat(15_000)}:ERROR_TAIL`
227+
const part = yield* session.updatePart({
228+
id: PartID.ascending(),
229+
messageID,
230+
sessionID: info.id,
231+
type: "tool",
232+
tool: "lookup",
233+
callID: "call-1",
234+
state: {
235+
status: "error",
236+
input: {},
237+
error,
238+
time: { start: Date.now(), end: Date.now() },
239+
},
240+
} satisfies SessionV1.ToolPart)
241+
242+
expect(part.state.status).toBe("error")
243+
if (part.state.status !== "error") return
244+
expect(part.state.error.length).toBeLessThanOrEqual(Truncate.MAX_ERROR_CHARS)
245+
expect(part.state.error).toContain("ERROR_HEAD")
246+
expect(part.state.error).toContain("ERROR_TAIL")
247+
expect(part.state.error).not.toContain("GIANT_MIDDLE")
248+
const outputPath = part.state.error.match(/full error was saved to: (.+)\n/)?.[1]
249+
expect(outputPath).toBeDefined()
250+
if (!outputPath) throw new Error("expected full error path")
251+
expect(yield* Effect.promise(() => Bun.file(outputPath).text())).toBe(error)
252+
}),
253+
)
254+
209255
it.live("remove works without an instance", () =>
210256
Effect.gen(function* () {
211257
const session = yield* SessionNs.Service

packages/opencode/test/tool/truncation.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,38 @@ const configuredLayer = (cfg: ConfigV1.Info) =>
2525
const configuredIt = (cfg: ConfigV1.Info) => testEffect(configuredLayer(cfg))
2626

2727
describe("Truncate", () => {
28+
describe("error", () => {
29+
test("uses a 10,000 character model-facing limit", () => {
30+
expect(Truncate.MAX_ERROR_CHARS).toBe(10_000)
31+
})
32+
33+
it.live("keeps short errors unchanged", () =>
34+
Effect.gen(function* () {
35+
const content = "short tool error with exact whitespace\n"
36+
const result = yield* (yield* Truncate.Service).error(content)
37+
38+
expect(result).toEqual({ content, truncated: false })
39+
}),
40+
)
41+
42+
it.live("saves the full error and returns a bounded head and tail", () =>
43+
Effect.gen(function* () {
44+
const content = `ERROR_HEAD:${"h".repeat(15_000)}GIANT_MIDDLE${"t".repeat(15_000)}:ERROR_TAIL`
45+
const result = yield* (yield* Truncate.Service).error(content)
46+
47+
expect(result.truncated).toBe(true)
48+
expect(result.content.length).toBeLessThanOrEqual(Truncate.MAX_ERROR_CHARS)
49+
expect(result.content).toContain("ERROR_HEAD")
50+
expect(result.content).toContain("ERROR_TAIL")
51+
expect(result.content).not.toContain("GIANT_MIDDLE")
52+
expect(result.content).toContain("the full error was saved to")
53+
if (!result.truncated) throw new Error("expected truncated")
54+
expect(result.content).toContain(result.outputPath)
55+
expect(yield* (yield* FSUtil.Service).readFileString(result.outputPath)).toBe(content)
56+
}),
57+
)
58+
})
59+
2860
describe("output", () => {
2961
it.live("truncates large json file by bytes", () =>
3062
Effect.gen(function* () {

0 commit comments

Comments
 (0)