Skip to content

Commit 667ed6b

Browse files
committed
feat(bash): add output_filter parameter for build command filtering
Add output_filter parameter that captures regex-matching lines inline while streaming full output to file. Useful for build commands where only warnings/errors matter. When a filter is provided: - Full output still streams to temp file (for later inspection) - Only matching lines are returned inline to the LLM - Filter stats (matchCount, filteredBytes) are tracked incrementally Example usage: output_filter: "^(warning|error|WARN|ERROR):" This avoids the LLM having to grep through large build outputs to find the relevant diagnostics. Assisted-by: OpenCode (Claude Sonnet 4)
1 parent f5c7f2c commit 667ed6b

5 files changed

Lines changed: 231 additions & 3 deletions

File tree

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { Prompt, type PromptRef } from "@tui/component/prompt"
3030
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2"
3131
import { useLocal } from "@tui/context/local"
3232
import { Locale } from "@/util/locale"
33+
import { formatSize } from "@/util/format"
3334
import type { Tool } from "@/tool/tool"
3435
import type { ReadTool } from "@/tool/read"
3536
import type { WriteTool } from "@/tool/write"
@@ -1608,6 +1609,14 @@ function Bash(props: ToolProps<typeof BashTool>) {
16081609
return [...lines().slice(0, 10), "…"].join("\n")
16091610
})
16101611

1612+
const filterInfo = createMemo(() => {
1613+
if (!props.metadata.filtered) return undefined
1614+
const total = formatSize(props.metadata.totalBytes ?? 0)
1615+
const omitted = formatSize(props.metadata.omittedBytes ?? 0)
1616+
const matches = props.metadata.matchCount ?? 0
1617+
return `Filtered: ${matches} match${matches === 1 ? "" : "es"} from ${total} (${omitted} omitted)`
1618+
})
1619+
16111620
const workdirDisplay = createMemo(() => {
16121621
const workdir = props.input.workdir
16131622
if (!workdir || workdir === ".") return undefined
@@ -1644,6 +1653,9 @@ function Bash(props: ToolProps<typeof BashTool>) {
16441653
<box gap={1}>
16451654
<text fg={theme.text}>$ {props.input.command}</text>
16461655
<text fg={theme.text}>{limited()}</text>
1656+
<Show when={filterInfo()}>
1657+
<text fg={theme.textMuted}>{filterInfo()}</text>
1658+
</Show>
16471659
<Show when={overflow()}>
16481660
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
16491661
</Show>

packages/opencode/src/tool/bash.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ export interface BashMetadata {
2727
description: string
2828
truncated?: boolean
2929
outputPath?: string
30+
filtered?: boolean
31+
filterPattern?: string
32+
matchCount?: number
33+
totalBytes?: number
34+
omittedBytes?: number
3035
}
3136

3237
const resolveWasm = (asset: string) => {
@@ -75,6 +80,12 @@ export const BashTool = Tool.define("bash", async () => {
7580
`The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`,
7681
)
7782
.optional(),
83+
output_filter: z
84+
.string()
85+
.describe(
86+
`Optional regex pattern to filter output. When set, full output streams to a file while lines matching the pattern are returned inline. Useful for build commands where you only care about warnings/errors. Example: "^(warning|error|WARN|ERROR):.*" to capture compiler diagnostics. The regex is matched against each line.`,
87+
)
88+
.optional(),
7889
description: z
7990
.string()
8091
.describe(
@@ -88,6 +99,15 @@ export const BashTool = Tool.define("bash", async () => {
8899
}
89100
const timeout = params.timeout ?? DEFAULT_TIMEOUT
90101

102+
// Parse output_filter regex if provided
103+
let filter: RegExp | undefined
104+
if (params.output_filter) {
105+
try {
106+
filter = new RegExp(params.output_filter)
107+
} catch (e) {
108+
throw new Error(`Invalid output_filter regex: ${params.output_filter}. ${e}`)
109+
}
110+
}
91111
const tree = await parser().then((p) => p.parse(params.command))
92112
if (!tree) {
93113
throw new Error("Failed to parse command")
@@ -162,7 +182,7 @@ export const BashTool = Tool.define("bash", async () => {
162182
})
163183
}
164184

165-
const streaming = new StreamingOutput()
185+
const streaming = new StreamingOutput({ filter })
166186

167187
const proc = spawn(params.command, {
168188
shell,
@@ -258,6 +278,30 @@ export const BashTool = Tool.define("bash", async () => {
258278
streaming.appendMetadata("\n\n<bash_metadata>\n" + resultMetadata.join("\n") + "\n</bash_metadata>")
259279
}
260280

281+
// If using filter, return filtered lines
282+
if (streaming.hasFilter) {
283+
const output = streaming.truncated
284+
? `${streaming.filteredOutput}\n${streaming.finalize(params.output_filter)}`
285+
: streaming.finalize(params.output_filter)
286+
287+
return {
288+
title: params.description,
289+
metadata: {
290+
output: streaming.filteredOutput || `[no matches for filter: ${params.output_filter}]`,
291+
exit: proc.exitCode,
292+
description: params.description,
293+
truncated: streaming.truncated,
294+
outputPath: streaming.outputPath,
295+
filtered: true,
296+
filterPattern: params.output_filter,
297+
matchCount: streaming.matchCount,
298+
totalBytes: streaming.totalBytes,
299+
omittedBytes: streaming.omittedBytes,
300+
} as BashMetadata,
301+
output,
302+
}
303+
}
304+
261305
// If we streamed to a file (threshold exceeded), return truncated result
262306
if (streaming.truncated) {
263307
return {
@@ -268,6 +312,7 @@ export const BashTool = Tool.define("bash", async () => {
268312
description: params.description,
269313
truncated: true,
270314
outputPath: streaming.outputPath,
315+
totalBytes: streaming.totalBytes,
271316
} as BashMetadata,
272317
output: streaming.finalize(),
273318
}

packages/opencode/src/tool/bash.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Usage notes:
2525
- 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.
2727
- 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.
28+
- For build commands (make, cargo build, npm run build, tsc, etc.) that produce lots of output where you only care about warnings/errors, use the `output_filter` parameter with a regex like "^(warning|error|WARN|ERROR):". This streams full output to a file while returning only matching lines inline, saving you from having to grep the output afterward.
2829

2930
- 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:
3031
- File search: Use Glob (NOT find or ls)

packages/opencode/src/tool/truncation.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,32 @@ const log = Log.create({ service: "truncation" })
1212

1313
export interface StreamingOutputOptions {
1414
threshold?: number
15+
/** Optional regex to filter output lines. Matching lines are collected separately. */
16+
filter?: RegExp
1517
}
1618

1719
/**
1820
* Streaming output accumulator that spills to disk when threshold is exceeded.
1921
* Avoids O(n²) memory growth from string concatenation.
22+
*
23+
* Optionally supports line filtering - when a filter regex is provided, matching
24+
* lines are collected separately while full output still streams to file.
2025
*/
2126
export class StreamingOutput {
2227
private output = ""
2328
private outputBytes = 0
2429
private streamFile: { fd: number; path: string } | undefined
2530
private streamedBytes = 0
2631
private threshold: number
32+
private filter?: RegExp
33+
private filtered = ""
34+
private filteredBytes = 0
35+
private filteredCount = 0
36+
private lineBuffer = ""
2737

2838
constructor(options: StreamingOutputOptions = {}) {
2939
this.threshold = options.threshold ?? Truncate.MAX_BYTES
40+
this.filter = options.filter
3041
}
3142

3243
/** Append a chunk of output. Returns the current preview string. */
@@ -46,11 +57,30 @@ export class StreamingOutput {
4657
this.output += text
4758
}
4859

60+
// Process filter if active
61+
if (this.filter) {
62+
this.lineBuffer += text
63+
const lines = this.lineBuffer.split("\n")
64+
this.lineBuffer = lines.pop() || ""
65+
for (const line of lines) {
66+
if (this.filter.test(line)) {
67+
const entry = line + "\n"
68+
this.filtered += entry
69+
this.filteredBytes += Buffer.byteLength(entry, "utf-8")
70+
this.filteredCount++
71+
}
72+
}
73+
}
74+
4975
return this.preview()
5076
}
5177

52-
/** Get current preview - either full output or streaming indicator */
78+
/** Get current preview - either full output, streaming indicator, or filter status */
5379
preview(): string {
80+
if (this.filter) {
81+
if (this.filtered) return this.filtered
82+
return `[filtering: ${this.outputBytes} bytes, ${this.matchCount} matches...]\n`
83+
}
5484
if (this.streamFile) {
5585
return `[streaming to file: ${this.streamedBytes} bytes written...]\n`
5686
}
@@ -77,8 +107,37 @@ export class StreamingOutput {
77107
return this.output
78108
}
79109

110+
/** Get filtered output (only when filter is active) */
111+
get filteredOutput(): string {
112+
return this.filtered
113+
}
114+
115+
/** Number of lines matching the filter */
116+
get matchCount(): number {
117+
return this.filteredCount
118+
}
119+
120+
/** Bytes omitted by filtering */
121+
get omittedBytes(): number {
122+
return this.totalBytes - this.filteredBytes
123+
}
124+
125+
/** Whether a filter is active */
126+
get hasFilter(): boolean {
127+
return this.filter !== undefined
128+
}
129+
80130
/** Close the stream file if open. Call this after command completes. */
81131
close(): void {
132+
// Process any remaining content in line buffer
133+
if (this.filter && this.lineBuffer) {
134+
if (this.filter.test(this.lineBuffer)) {
135+
const entry = this.lineBuffer + "\n"
136+
this.filtered += entry
137+
this.filteredBytes += Buffer.byteLength(entry, "utf-8")
138+
this.filteredCount++
139+
}
140+
}
82141
if (this.streamFile) {
83142
fsSync.closeSync(this.streamFile.fd)
84143
}
@@ -94,7 +153,13 @@ export class StreamingOutput {
94153
}
95154

96155
/** Get final output string (for non-truncated) or hint message (for truncated) */
97-
finalize(): string {
156+
finalize(filterPattern?: string): string {
157+
if (this.filter) {
158+
if (this.streamFile) {
159+
return `Filtered ${this.matchCount} matching lines from ${this.totalBytes} bytes of output.\nFull output saved to: ${this.streamFile.path}\nUse Grep to search or Read with offset/limit to view specific sections.\nNote: This file will be deleted after a few more commands. Copy it if you need to preserve it.`
160+
}
161+
return this.filtered || `[no matches for filter: ${filterPattern}]`
162+
}
98163
if (this.streamFile) {
99164
return `The command output was ${this.streamedBytes} bytes and was truncated (inline limit: ${this.threshold} bytes).\nFull output saved to: ${this.streamFile.path}\nUse Grep to search the full content or Read with offset/limit to view specific sections.\nNote: This file will be deleted after a few more commands. Copy it if you need to preserve it.`
100165
}

packages/opencode/test/tool/bash.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,4 +408,109 @@ describe("tool.bash truncation", () => {
408408
},
409409
})
410410
})
411+
412+
test("output_filter captures matching lines in memory for small output", async () => {
413+
await Instance.provide({
414+
directory: projectRoot,
415+
fn: async () => {
416+
const bash = await BashTool.init()
417+
// Small build output with warnings/errors - stays in memory
418+
const result = await bash.execute(
419+
{
420+
command: `echo "compiling..."; echo "warning: unused variable"; echo "done"; echo "error: type mismatch"`,
421+
output_filter: "^(warning|error):",
422+
description: "Build with filter (small)",
423+
},
424+
ctx,
425+
)
426+
427+
// Should NOT be truncated (small output stays in memory)
428+
expect((result.metadata as any).truncated).toBe(false)
429+
expect((result.metadata as any).filtered).toBe(true)
430+
expect((result.metadata as any).matchCount).toBe(2)
431+
432+
// The output should contain only the filtered lines
433+
expect(result.output).toContain("warning: unused variable")
434+
expect(result.output).toContain("error: type mismatch")
435+
expect(result.output).not.toContain("compiling...")
436+
expect(result.output).not.toContain("done")
437+
},
438+
})
439+
})
440+
441+
test("output_filter streams to file when output exceeds threshold", async () => {
442+
await Instance.provide({
443+
directory: projectRoot,
444+
fn: async () => {
445+
const bash = await BashTool.init()
446+
// Generate large output that exceeds threshold
447+
const byteCount = Truncate.MAX_BYTES * 2
448+
const result = await bash.execute(
449+
{
450+
command: `head -c ${byteCount} /dev/zero | tr '\\0' 'x'; echo ""; echo "warning: this is a warning"`,
451+
output_filter: "^warning:",
452+
description: "Build with filter (large)",
453+
},
454+
ctx,
455+
)
456+
457+
// Should be truncated (large output spills to file)
458+
expect((result.metadata as any).truncated).toBe(true)
459+
expect((result.metadata as any).filtered).toBe(true)
460+
const filepath = (result.metadata as any).outputPath
461+
expect(filepath).toBeTruthy()
462+
463+
// The inline output should contain only the filtered line
464+
expect(result.output).toContain("warning: this is a warning")
465+
expect(result.output).toContain("Filtered 1 matching line")
466+
467+
// The full output file should contain everything
468+
const saved = await Bun.file(filepath).text()
469+
expect(saved.length).toBeGreaterThan(byteCount)
470+
expect(saved).toContain("warning: this is a warning")
471+
},
472+
})
473+
})
474+
475+
test("output_filter with no matches returns empty filtered output", async () => {
476+
await Instance.provide({
477+
directory: projectRoot,
478+
fn: async () => {
479+
const bash = await BashTool.init()
480+
const result = await bash.execute(
481+
{
482+
command: `echo "all good"; echo "no problems here"`,
483+
output_filter: "^(warning|error):",
484+
description: "Build with no matches",
485+
},
486+
ctx,
487+
)
488+
489+
// Small output stays in memory, no truncation
490+
expect((result.metadata as any).truncated).toBe(false)
491+
expect((result.metadata as any).filtered).toBe(true)
492+
expect((result.metadata as any).matchCount).toBe(0)
493+
expect(result.output).toContain("[no matches for filter:")
494+
},
495+
})
496+
})
497+
498+
test("invalid output_filter regex throws error", async () => {
499+
await Instance.provide({
500+
directory: projectRoot,
501+
fn: async () => {
502+
const bash = await BashTool.init()
503+
await expect(
504+
bash.execute(
505+
{
506+
command: `echo test`,
507+
output_filter: "[invalid(regex",
508+
description: "Invalid regex test",
509+
},
510+
ctx,
511+
),
512+
).rejects.toThrow("Invalid output_filter regex")
513+
},
514+
})
515+
})
411516
})

0 commit comments

Comments
 (0)