Skip to content

Commit 36f3c0d

Browse files
authored
Merge pull request #138 from browser-use/bound-tool-errors
fix(opencode): share tool response truncation
2 parents 1ff4926 + a25fda2 commit 36f3c0d

4 files changed

Lines changed: 88 additions & 15 deletions

File tree

‎packages/opencode/src/session/llm.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ 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

@@ -70,6 +71,7 @@ const live: Layer.Layer<
7071
| EventV2Bridge.Service
7172
| LLMClientService
7273
| RuntimeFlags.Service
74+
| Truncate.Service
7375
> = Layer.effect(
7476
Service,
7577
Effect.gen(function* () {
@@ -81,6 +83,7 @@ const live: Layer.Layer<
8183
const events = yield* EventV2Bridge.Service
8284
const llmClient = yield* LLMClient.Service
8385
const flags = yield* RuntimeFlags.Service
86+
const truncate = yield* Truncate.Service
8487

8588
const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
8689
yield* Effect.logInfo("stream", {
@@ -142,7 +145,8 @@ const live: Layer.Layer<
142145
title: typeof result === "object" ? result?.title : undefined,
143146
}
144147
} catch (e: any) {
145-
return { result: "", error: e.message ?? String(e) }
148+
const error = await bridge.promise(truncate.output(e.message ?? String(e)))
149+
return { result: "", error: error.content }
146150
}
147151
}
148152

@@ -301,11 +305,12 @@ const live: Layer.Layer<
301305
toolName: lower,
302306
}
303307
}
308+
const error = await bridge.promise(truncate.output(failed.error.message))
304309
return {
305310
...failed.toolCall,
306311
input: JSON.stringify({
307312
tool: failed.toolCall.toolName,
308-
error: failed.error.message,
313+
error: error.content,
309314
}),
310315
toolName: "invalid",
311316
}
@@ -398,6 +403,7 @@ export const node = LayerNode.make({
398403
EventV2Bridge.node,
399404
llmClient,
400405
RuntimeFlags.node,
406+
Truncate.node,
401407
],
402408
})
403409

‎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.output(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: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
22
import { NodePath } from "@effect/platform-node"
3-
import { Cause, Duration, Effect, Layer, Option, Schedule, Context } from "effect"
3+
import { Cause, Duration, Effect, Exit, Layer, Option, Schedule, Context } from "effect"
44
import path from "path"
55
import type { Agent } from "../agent/agent"
66
import { FSUtil } from "@opencode-ai/core/fs-util"
@@ -13,11 +13,11 @@ import { TRUNCATION_DIR } from "./truncation-dir"
1313
const RETENTION = Duration.days(7)
1414

1515
export const MAX_LINES = 2000
16-
export const MAX_BYTES = 50 * 1024
16+
export const MAX_BYTES = 40 * 1024
1717
export const DIR = TRUNCATION_DIR
1818
export const GLOB = path.join(TRUNCATION_DIR, "*")
1919

20-
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
20+
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath?: string }
2121

2222
export interface Options {
2323
maxLines?: number
@@ -124,19 +124,25 @@ const layer = Layer.effect(
124124
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
125125
const unit = hitBytes ? "bytes" : "lines"
126126
const preview = out.join("\n")
127-
const file = yield* write(text)
127+
const saved = yield* write(text).pipe(Effect.exit)
128+
const file = Exit.isSuccess(saved) ? saved.value : undefined
129+
if (Exit.isFailure(saved)) {
130+
yield* Effect.logWarning("failed to save full truncated tool response", { cause: Cause.pretty(saved.cause) })
131+
}
128132

129-
const hint = hasTaskTool(agent)
130-
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
131-
: `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
133+
const hint = file
134+
? hasTaskTool(agent)
135+
? `The tool response was truncated. Full content saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
136+
: `The tool response was truncated. Full content saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
137+
: "The tool response was truncated, and the full content could not be saved."
132138

133139
return {
134140
content:
135141
direction === "head"
136142
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
137143
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
138144
truncated: true,
139-
outputPath: file,
145+
...(file ? { outputPath: file } : {}),
140146
} as const
141147
})
142148

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

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { describe, test, expect } from "bun:test"
2+
import { JSONParseError } from "@ai-sdk/provider"
23
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
34
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
45
import { filesystem } from "@opencode-ai/core/effect/app-node-platform"
56
import { FSUtil } from "@opencode-ai/core/fs-util"
6-
import { Effect, FileSystem } from "effect"
7+
import { Effect, FileSystem, Layer } from "effect"
78
import { Truncate } from "@/tool/truncate"
89
import { Config } from "@/config/config"
910
import { Identifier } from "../../src/id/id"
@@ -12,11 +13,26 @@ import path from "path"
1213
import { testEffect } from "../lib/effect"
1314
import { writeFileStringScoped } from "../lib/filesystem"
1415
import { TestConfig } from "../fixture/config"
16+
import { InvalidToolInputError } from "ai"
1517

1618
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
1719
const ROOT = path.resolve(import.meta.dir, "..", "..")
1820

1921
const it = testEffect(LayerNode.compile(LayerNode.group([Truncate.node, FSUtil.node, filesystem])))
22+
const failedWriteFS = Layer.effect(
23+
FSUtil.Service,
24+
FSUtil.Service.pipe(
25+
Effect.map((fs) =>
26+
FSUtil.Service.of({
27+
...fs,
28+
writeFileString: () => Effect.die("blocked test write"),
29+
}),
30+
),
31+
),
32+
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
33+
const failedWriteIt = testEffect(
34+
LayerNode.compile(LayerNode.group([Truncate.node, FSUtil.node, filesystem]), [[FSUtil.node, failedWriteFS]]),
35+
)
2036

2137
const configuredLayer = (cfg: ConfigV1.Info) =>
2238
LayerNode.compile(LayerNode.group([Truncate.node, FSUtil.node, filesystem, Config.node]), [
@@ -102,7 +118,7 @@ describe("Truncate", () => {
102118

103119
test("uses default MAX_LINES and MAX_BYTES", () => {
104120
expect(Truncate.MAX_LINES).toBe(2000)
105-
expect(Truncate.MAX_BYTES).toBe(50 * 1024)
121+
expect(Truncate.MAX_BYTES).toBe(40 * 1024)
106122
})
107123

108124
it.live("limits() falls back to MAX_LINES/MAX_BYTES when Config is not provided", () =>
@@ -180,7 +196,7 @@ describe("Truncate", () => {
180196
const result = yield* svc.output(lines, { maxLines: 10 })
181197

182198
expect(result.truncated).toBe(true)
183-
expect(result.content).toContain("The tool call succeeded but the output was truncated")
199+
expect(result.content).toContain("The tool response was truncated")
184200
expect(result.content).toContain("Grep")
185201
if (!result.truncated) throw new Error("expected truncated")
186202
expect(result.outputPath).toBeDefined()
@@ -192,6 +208,45 @@ describe("Truncate", () => {
192208
}),
193209
)
194210

211+
it.live("archives the production-shaped malformed tool error", () =>
212+
Effect.gen(function* () {
213+
const malformed =
214+
'{"description":"Test resumed player extraction","code":"const x=1;' + "\n\t".repeat(118_000) + '"}'
215+
let cause: unknown
216+
try {
217+
JSON.parse(malformed)
218+
} catch (error) {
219+
cause = error
220+
}
221+
if (!cause) throw new Error("expected malformed input to fail JSON parsing")
222+
223+
const failed = new InvalidToolInputError({
224+
toolName: "browser_execute",
225+
toolInput: malformed,
226+
cause: new JSONParseError({ text: malformed, cause }),
227+
})
228+
const result = yield* (yield* Truncate.Service).output(failed.message)
229+
expect(result.truncated).toBe(true)
230+
if (!result.truncated || !result.outputPath) throw new Error("expected archived malformed-tool error")
231+
232+
const stored = JSON.stringify({ tool: "browser_execute", error: result.content })
233+
expect(stored.length).toBeLessThanOrEqual(Truncate.MAX_BYTES)
234+
expect(JSON.parse(stored).error).toContain(result.outputPath)
235+
expect(yield* (yield* FSUtil.Service).readFileString(result.outputPath)).toBe(failed.message)
236+
}),
237+
)
238+
239+
failedWriteIt.live("keeps a bounded response when the full content cannot be saved", () =>
240+
Effect.gen(function* () {
241+
const result = yield* (yield* Truncate.Service).output("x".repeat(Truncate.MAX_BYTES + 1))
242+
243+
expect(result.truncated).toBe(true)
244+
if (!result.truncated) throw new Error("expected truncated output")
245+
expect(result.outputPath).toBeUndefined()
246+
expect(result.content).toContain("full content could not be saved")
247+
}),
248+
)
249+
195250
it.live("suggests Task tool when agent has task permission", () =>
196251
Effect.gen(function* () {
197252
const svc = yield* Truncate.Service

0 commit comments

Comments
 (0)