Skip to content

Commit 48dc829

Browse files
fix(webfetch): apply aggressive truncation for webfetch outputs (#434)
Root cause: DEFAULT_TARGET_MAX_TOKENS (50k tokens ~200k chars) was too high for webfetch outputs. Web pages can be large but most content doesn't exceed this limit, so truncation rarely triggered. Changes: - Add WEBFETCH_MAX_TOKENS = 10k tokens (~40k chars) for web content - Introduce TOOL_SPECIFIC_MAX_TOKENS map for per-tool limits - webfetch/WebFetch now use aggressive 10k token limit - Other tools continue using default 50k token limit - Add comprehensive tests for truncation behavior Fixes #195 Co-authored-by: sisyphus-dev-ai <[email protected]>
1 parent 8bc9d6a commit 48dc829

File tree

2 files changed

+182
-1
lines changed

2 files changed

+182
-1
lines changed
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test"
2+
import { createToolOutputTruncatorHook } from "./tool-output-truncator"
3+
import * as dynamicTruncator from "../shared/dynamic-truncator"
4+
5+
describe("createToolOutputTruncatorHook", () => {
6+
let hook: ReturnType<typeof createToolOutputTruncatorHook>
7+
let truncateSpy: ReturnType<typeof spyOn>
8+
9+
beforeEach(() => {
10+
truncateSpy = spyOn(dynamicTruncator, "createDynamicTruncator").mockReturnValue({
11+
truncate: mock(async (_sessionID: string, output: string, options?: { targetMaxTokens?: number }) => ({
12+
result: output,
13+
truncated: false,
14+
targetMaxTokens: options?.targetMaxTokens,
15+
})),
16+
getUsage: mock(async () => null),
17+
truncateSync: mock(() => ({ result: "", truncated: false })),
18+
})
19+
hook = createToolOutputTruncatorHook({} as never)
20+
})
21+
22+
describe("tool.execute.after", () => {
23+
const createInput = (tool: string) => ({
24+
tool,
25+
sessionID: "test-session",
26+
callID: "test-call-id",
27+
})
28+
29+
const createOutput = (outputText: string) => ({
30+
title: "Result",
31+
output: outputText,
32+
metadata: {},
33+
})
34+
35+
describe("#given webfetch tool", () => {
36+
describe("#when output is processed", () => {
37+
it("#then should use aggressive truncation limit (10k tokens)", async () => {
38+
const truncateMock = mock(async (_sessionID: string, _output: string, options?: { targetMaxTokens?: number }) => ({
39+
result: "truncated",
40+
truncated: true,
41+
targetMaxTokens: options?.targetMaxTokens,
42+
}))
43+
truncateSpy.mockReturnValue({
44+
truncate: truncateMock,
45+
getUsage: mock(async () => null),
46+
truncateSync: mock(() => ({ result: "", truncated: false })),
47+
})
48+
hook = createToolOutputTruncatorHook({} as never)
49+
50+
const input = createInput("webfetch")
51+
const output = createOutput("large content")
52+
53+
await hook["tool.execute.after"](input, output)
54+
55+
expect(truncateMock).toHaveBeenCalledWith(
56+
"test-session",
57+
"large content",
58+
{ targetMaxTokens: 10_000 }
59+
)
60+
})
61+
})
62+
63+
describe("#when using WebFetch variant", () => {
64+
it("#then should also use aggressive truncation limit", async () => {
65+
const truncateMock = mock(async (_sessionID: string, _output: string, options?: { targetMaxTokens?: number }) => ({
66+
result: "truncated",
67+
truncated: true,
68+
}))
69+
truncateSpy.mockReturnValue({
70+
truncate: truncateMock,
71+
getUsage: mock(async () => null),
72+
truncateSync: mock(() => ({ result: "", truncated: false })),
73+
})
74+
hook = createToolOutputTruncatorHook({} as never)
75+
76+
const input = createInput("WebFetch")
77+
const output = createOutput("large content")
78+
79+
await hook["tool.execute.after"](input, output)
80+
81+
expect(truncateMock).toHaveBeenCalledWith(
82+
"test-session",
83+
"large content",
84+
{ targetMaxTokens: 10_000 }
85+
)
86+
})
87+
})
88+
})
89+
90+
describe("#given grep tool", () => {
91+
describe("#when output is processed", () => {
92+
it("#then should use default truncation limit (50k tokens)", async () => {
93+
const truncateMock = mock(async (_sessionID: string, _output: string, options?: { targetMaxTokens?: number }) => ({
94+
result: "truncated",
95+
truncated: true,
96+
}))
97+
truncateSpy.mockReturnValue({
98+
truncate: truncateMock,
99+
getUsage: mock(async () => null),
100+
truncateSync: mock(() => ({ result: "", truncated: false })),
101+
})
102+
hook = createToolOutputTruncatorHook({} as never)
103+
104+
const input = createInput("grep")
105+
const output = createOutput("grep output")
106+
107+
await hook["tool.execute.after"](input, output)
108+
109+
expect(truncateMock).toHaveBeenCalledWith(
110+
"test-session",
111+
"grep output",
112+
{ targetMaxTokens: 50_000 }
113+
)
114+
})
115+
})
116+
})
117+
118+
describe("#given non-truncatable tool", () => {
119+
describe("#when tool is not in TRUNCATABLE_TOOLS list", () => {
120+
it("#then should not call truncator", async () => {
121+
const truncateMock = mock(async () => ({
122+
result: "truncated",
123+
truncated: true,
124+
}))
125+
truncateSpy.mockReturnValue({
126+
truncate: truncateMock,
127+
getUsage: mock(async () => null),
128+
truncateSync: mock(() => ({ result: "", truncated: false })),
129+
})
130+
hook = createToolOutputTruncatorHook({} as never)
131+
132+
const input = createInput("Read")
133+
const output = createOutput("file content")
134+
135+
await hook["tool.execute.after"](input, output)
136+
137+
expect(truncateMock).not.toHaveBeenCalled()
138+
})
139+
})
140+
})
141+
142+
describe("#given truncate_all_tool_outputs enabled", () => {
143+
describe("#when any tool output is processed", () => {
144+
it("#then should truncate non-listed tools too", async () => {
145+
const truncateMock = mock(async (_sessionID: string, _output: string, options?: { targetMaxTokens?: number }) => ({
146+
result: "truncated",
147+
truncated: true,
148+
}))
149+
truncateSpy.mockReturnValue({
150+
truncate: truncateMock,
151+
getUsage: mock(async () => null),
152+
truncateSync: mock(() => ({ result: "", truncated: false })),
153+
})
154+
hook = createToolOutputTruncatorHook({} as never, {
155+
experimental: { truncate_all_tool_outputs: true },
156+
})
157+
158+
const input = createInput("Read")
159+
const output = createOutput("file content")
160+
161+
await hook["tool.execute.after"](input, output)
162+
163+
expect(truncateMock).toHaveBeenCalled()
164+
})
165+
})
166+
})
167+
})
168+
})

src/hooks/tool-output-truncator.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import type { PluginInput } from "@opencode-ai/plugin"
22
import type { ExperimentalConfig } from "../config/schema"
33
import { createDynamicTruncator } from "../shared/dynamic-truncator"
44

5+
const DEFAULT_MAX_TOKENS = 50_000 // ~200k chars
6+
const WEBFETCH_MAX_TOKENS = 10_000 // ~40k chars - web pages need aggressive truncation
7+
58
const TRUNCATABLE_TOOLS = [
69
"grep",
710
"Grep",
@@ -21,6 +24,11 @@ const TRUNCATABLE_TOOLS = [
2124
"WebFetch",
2225
]
2326

27+
const TOOL_SPECIFIC_MAX_TOKENS: Record<string, number> = {
28+
webfetch: WEBFETCH_MAX_TOKENS,
29+
WebFetch: WEBFETCH_MAX_TOKENS,
30+
}
31+
2432
interface ToolOutputTruncatorOptions {
2533
experimental?: ExperimentalConfig
2634
}
@@ -36,7 +44,12 @@ export function createToolOutputTruncatorHook(ctx: PluginInput, options?: ToolOu
3644
if (!truncateAll && !TRUNCATABLE_TOOLS.includes(input.tool)) return
3745

3846
try {
39-
const { result, truncated } = await truncator.truncate(input.sessionID, output.output)
47+
const targetMaxTokens = TOOL_SPECIFIC_MAX_TOKENS[input.tool] ?? DEFAULT_MAX_TOKENS
48+
const { result, truncated } = await truncator.truncate(
49+
input.sessionID,
50+
output.output,
51+
{ targetMaxTokens }
52+
)
4053
if (truncated) {
4154
output.output = result
4255
}

0 commit comments

Comments
 (0)