Skip to content

Commit 1b82511

Browse files
authored
feat: write truncated tool outputs to files (#7239)
1 parent f243144 commit 1b82511

File tree

14 files changed

+539
-177
lines changed

14 files changed

+539
-177
lines changed

packages/opencode/src/agent/agent.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Provider } from "../provider/provider"
44
import { generateObject, type ModelMessage } from "ai"
55
import { SystemPrompt } from "../session/system"
66
import { Instance } from "../project/instance"
7+
import { Truncate } from "../tool/truncation"
78

89
import PROMPT_GENERATE from "./generate.txt"
910
import PROMPT_COMPACTION from "./prompt/compaction.txt"
@@ -46,7 +47,10 @@ export namespace Agent {
4647
const defaults = PermissionNext.fromConfig({
4748
"*": "allow",
4849
doom_loop: "ask",
49-
external_directory: "ask",
50+
external_directory: {
51+
"*": "ask",
52+
[Truncate.DIR]: "allow",
53+
},
5054
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
5155
read: {
5256
"*": "allow",
@@ -110,6 +114,9 @@ export namespace Agent {
110114
websearch: "allow",
111115
codesearch: "allow",
112116
read: "allow",
117+
external_directory: {
118+
[Truncate.DIR]: "allow",
119+
},
113120
}),
114121
user,
115122
),

packages/opencode/src/id/id.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export namespace Identifier {
99
user: "usr",
1010
part: "prt",
1111
pty: "pty",
12+
tool: "tool",
1213
} as const
1314

1415
export function schema(prefix: keyof typeof prefixes) {
@@ -70,4 +71,12 @@ export namespace Identifier {
7071

7172
return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
7273
}
74+
75+
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
76+
export function timestamp(id: string): number {
77+
const prefix = id.split("_")[0]
78+
const hex = id.slice(prefix.length + 1, prefix.length + 13)
79+
const encoded = BigInt("0x" + hex)
80+
return Number(encoded / BigInt(0x1000))
81+
}
7382
}

packages/opencode/src/session/truncation.ts

Lines changed: 0 additions & 60 deletions
This file was deleted.

packages/opencode/src/tool/bash.ts

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ import { Flag } from "@/flag/flag.ts"
1515
import { Shell } from "@/shell/shell"
1616

1717
import { BashArity } from "@/permission/arity"
18+
import { Truncate } from "./truncation"
1819

19-
const MAX_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH || 30_000
20+
const MAX_METADATA_LENGTH = 30_000
2021
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
2122

2223
export const log = Log.create({ service: "bash-tool" })
@@ -55,7 +56,9 @@ export const BashTool = Tool.define("bash", async () => {
5556
log.info("bash tool using shell", { shell })
5657

5758
return {
58-
description: DESCRIPTION.replaceAll("${directory}", Instance.directory),
59+
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
60+
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
61+
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
5962
parameters: z.object({
6063
command: z.string().describe("The command to execute"),
6164
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
@@ -172,15 +175,14 @@ export const BashTool = Tool.define("bash", async () => {
172175
})
173176

174177
const append = (chunk: Buffer) => {
175-
if (output.length <= MAX_OUTPUT_LENGTH) {
176-
output += chunk.toString()
177-
ctx.metadata({
178-
metadata: {
179-
output,
180-
description: params.description,
181-
},
182-
})
183-
}
178+
output += chunk.toString()
179+
ctx.metadata({
180+
metadata: {
181+
// truncate the metadata to avoid GIANT blobs of data (has nothing to do w/ what agent can access)
182+
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
183+
description: params.description,
184+
},
185+
})
184186
}
185187

186188
proc.stdout?.on("data", append)
@@ -228,12 +230,7 @@ export const BashTool = Tool.define("bash", async () => {
228230
})
229231
})
230232

231-
let resultMetadata: String[] = ["<bash_metadata>"]
232-
233-
if (output.length > MAX_OUTPUT_LENGTH) {
234-
output = output.slice(0, MAX_OUTPUT_LENGTH)
235-
resultMetadata.push(`bash tool truncated output as it exceeded ${MAX_OUTPUT_LENGTH} char limit`)
236-
}
233+
const resultMetadata: string[] = []
237234

238235
if (timedOut) {
239236
resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`)
@@ -243,15 +240,14 @@ export const BashTool = Tool.define("bash", async () => {
243240
resultMetadata.push("User aborted the command")
244241
}
245242

246-
if (resultMetadata.length > 1) {
247-
resultMetadata.push("</bash_metadata>")
248-
output += "\n\n" + resultMetadata.join("\n")
243+
if (resultMetadata.length > 0) {
244+
output += "\n\n<bash_metadata>\n" + resultMetadata.join("\n") + "\n</bash_metadata>"
249245
}
250246

251247
return {
252248
title: params.description,
253249
metadata: {
254-
output,
250+
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
255251
exit: proc.exitCode,
256252
description: params.description,
257253
},

packages/opencode/src/tool/bash.txt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,9 @@ Before executing the command, please follow these steps:
2222

2323
Usage notes:
2424
- The command argument is required.
25-
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will time out after 120000ms (2 minutes).
25+
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).
2626
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
27-
- If the output exceeds 30000 characters, output will be truncated before being returned to you.
28-
- You can use the `run_in_background` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the Bash tool as it becomes available. You do not need to use '&' at the end of the command when using this parameter.
27+
- If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Because of this, you do NOT need to use `head`, `tail`, or other truncation commands to limit output - just run the command directly.
2928

3029
- Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
3130
- File search: Use Glob (NOT find or ls)

packages/opencode/src/tool/read.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Identifier } from "../id/id"
1111

1212
const DEFAULT_READ_LIMIT = 2000
1313
const MAX_LINE_LENGTH = 2000
14+
const MAX_BYTES = 50 * 1024
1415

1516
export const ReadTool = Tool.define("read", {
1617
description: DESCRIPTION,
@@ -77,6 +78,7 @@ export const ReadTool = Tool.define("read", {
7778
output: msg,
7879
metadata: {
7980
preview: msg,
81+
truncated: false,
8082
},
8183
attachments: [
8284
{
@@ -97,9 +99,21 @@ export const ReadTool = Tool.define("read", {
9799
const limit = params.limit ?? DEFAULT_READ_LIMIT
98100
const offset = params.offset || 0
99101
const lines = await file.text().then((text) => text.split("\n"))
100-
const raw = lines.slice(offset, offset + limit).map((line) => {
101-
return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line
102-
})
102+
103+
const raw: string[] = []
104+
let bytes = 0
105+
let truncatedByBytes = false
106+
for (let i = offset; i < Math.min(lines.length, offset + limit); i++) {
107+
const line = lines[i].length > MAX_LINE_LENGTH ? lines[i].substring(0, MAX_LINE_LENGTH) + "..." : lines[i]
108+
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
109+
if (bytes + size > MAX_BYTES) {
110+
truncatedByBytes = true
111+
break
112+
}
113+
raw.push(line)
114+
bytes += size
115+
}
116+
103117
const content = raw.map((line, index) => {
104118
return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`
105119
})
@@ -109,10 +123,13 @@ export const ReadTool = Tool.define("read", {
109123
output += content.join("\n")
110124

111125
const totalLines = lines.length
112-
const lastReadLine = offset + content.length
126+
const lastReadLine = offset + raw.length
113127
const hasMoreLines = totalLines > lastReadLine
128+
const truncated = hasMoreLines || truncatedByBytes
114129

115-
if (hasMoreLines) {
130+
if (truncatedByBytes) {
131+
output += `\n\n(Output truncated at ${MAX_BYTES} bytes. Use 'offset' parameter to read beyond line ${lastReadLine})`
132+
} else if (hasMoreLines) {
116133
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${lastReadLine})`
117134
} else {
118135
output += `\n\n(End of file - total ${totalLines} lines)`
@@ -128,6 +145,7 @@ export const ReadTool = Tool.define("read", {
128145
output,
129146
metadata: {
130147
preview,
148+
truncated,
131149
},
132150
}
133151
},

packages/opencode/src/tool/registry.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { CodeSearchTool } from "./codesearch"
2323
import { Flag } from "@/flag/flag"
2424
import { Log } from "@/util/log"
2525
import { LspTool } from "./lsp"
26-
import { Truncate } from "../session/truncation"
26+
import { Truncate } from "./truncation"
2727

2828
export namespace ToolRegistry {
2929
const log = Log.create({ service: "tool.registry" })
@@ -60,16 +60,16 @@ export namespace ToolRegistry {
6060
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
6161
return {
6262
id,
63-
init: async () => ({
63+
init: async (initCtx) => ({
6464
parameters: z.object(def.args),
6565
description: def.description,
6666
execute: async (args, ctx) => {
6767
const result = await def.execute(args as any, ctx)
68-
const out = Truncate.output(result)
68+
const out = await Truncate.output(result, {}, initCtx?.agent)
6969
return {
7070
title: "",
7171
output: out.truncated ? out.content : result,
72-
metadata: { truncated: out.truncated },
72+
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
7373
}
7474
},
7575
}),

packages/opencode/src/tool/tool.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import z from "zod"
22
import type { MessageV2 } from "../session/message-v2"
33
import type { Agent } from "../agent/agent"
44
import type { PermissionNext } from "../permission/next"
5-
import { Truncate } from "../session/truncation"
5+
import { Truncate } from "./truncation"
66

77
export namespace Tool {
88
interface Metadata {
@@ -50,8 +50,8 @@ export namespace Tool {
5050
): Info<Parameters, Result> {
5151
return {
5252
id,
53-
init: async (ctx) => {
54-
const toolInfo = init instanceof Function ? await init(ctx) : init
53+
init: async (initCtx) => {
54+
const toolInfo = init instanceof Function ? await init(initCtx) : init
5555
const execute = toolInfo.execute
5656
toolInfo.execute = async (args, ctx) => {
5757
try {
@@ -66,13 +66,18 @@ export namespace Tool {
6666
)
6767
}
6868
const result = await execute(args, ctx)
69-
const truncated = Truncate.output(result.output)
69+
// skip truncation for tools that handle it themselves
70+
if (result.metadata.truncated !== undefined) {
71+
return result
72+
}
73+
const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
7074
return {
7175
...result,
7276
output: truncated.content,
7377
metadata: {
7478
...result.metadata,
7579
truncated: truncated.truncated,
80+
...(truncated.truncated && { outputPath: truncated.outputPath }),
7681
},
7782
}
7883
}

0 commit comments

Comments
 (0)