Skip to content

Commit e2170ba

Browse files
authored
🤖 feat: add filter_exclude to bash_output tool (#1081)
Add a boolean `filter_exclude` parameter to the `bash_output` tool that inverts the filter behavior: - When `true`, lines matching `filter` are excluded instead of kept - **Key behavior**: excluded lines do NOT cause early return from timeout - Waiting continues until non-excluded output arrives or process exits ## Problem When polling `bash_output` for long-running processes like `wait_pr_checks.sh`, agents generate excessive tool calls because: 1. Scripts emit periodic progress output (e.g., `⏳ Checks in progress...` every 5s) 2. `timeout_secs` only sets upper bound - returns early when ANY output arrives 3. Agent sees output, polls again, sees more progress, polls again... ## Solution With `filter_exclude`: ``` bash_output(process_id="...", filter="⏳", filter_exclude=true, timeout_secs=60) ``` The agent can set a long timeout and only wake when meaningful output (✅ or ❌) arrives. ## Tests Added - Basic exclusion works - Error when `filter_exclude` without `filter` - Keeps waiting when only excluded lines arrive - Returns when process exits (even if only excluded output) - Timeout returns (doesn't hang) with only excluded output _Generated with `mux`_
1 parent f3a5890 commit e2170ba

File tree

8 files changed

+278
-26
lines changed

8 files changed

+278
-26
lines changed

src/browser/components/tools/BashOutputToolCall.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export const BashOutputToolCall: React.FC<BashOutputToolCallProps> = ({
5252
<Layers size={10} />
5353
output
5454
{args.timeout_secs > 0 && ` • wait ${args.timeout_secs}s`}
55-
{args.filter && ` • filter: ${args.filter}`}
55+
{args.filter && ` • ${args.filter_exclude ? "exclude" : "filter"}: ${args.filter}`}
5656
{groupPosition && (
5757
<span className="text-muted ml-1 flex items-center gap-0.5">
5858
<Link size={8} /> {groupPosition === "first" ? "start" : "end"}

src/browser/stories/App.bash.stories.tsx

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,3 +434,101 @@ export const GroupedOutput: AppStory = {
434434
/>
435435
),
436436
};
437+
438+
/**
439+
* Story: Filter Exclude
440+
* Demonstrates the filter_exclude parameter for bash_output.
441+
* Shows both regular filter (include) and filter_exclude (exclude) modes.
442+
*/
443+
export const FilterExclude: AppStory = {
444+
render: () => (
445+
<AppWithMocks
446+
setup={() =>
447+
setupSimpleChatStory({
448+
workspaceId: "ws-filter-exclude",
449+
messages: [
450+
// Background process started (CI checks)
451+
createUserMessage("msg-1", "Run CI checks and wait for completion", {
452+
historySequence: 1,
453+
timestamp: STABLE_TIMESTAMP - 400000,
454+
}),
455+
createAssistantMessage("msg-2", "Starting CI checks:", {
456+
historySequence: 2,
457+
timestamp: STABLE_TIMESTAMP - 390000,
458+
toolCalls: [
459+
createBackgroundBashTool(
460+
"call-1",
461+
"./scripts/wait_pr_checks.sh 1081",
462+
"bash_ci",
463+
"Wait PR Checks"
464+
),
465+
],
466+
}),
467+
// Polling with filter_exclude to skip progress spam
468+
createUserMessage("msg-3", "Wait for the result", {
469+
historySequence: 3,
470+
timestamp: STABLE_TIMESTAMP - 300000,
471+
}),
472+
createAssistantMessage(
473+
"msg-4",
474+
"Waiting for CI with filter_exclude to skip progress messages:",
475+
{
476+
historySequence: 4,
477+
timestamp: STABLE_TIMESTAMP - 290000,
478+
toolCalls: [
479+
// First call - using filter_exclude to skip ⏳ lines
480+
createBashOutputTool(
481+
"call-2",
482+
"bash_ci",
483+
"", // No meaningful output yet (⏳ lines were excluded)
484+
"running",
485+
undefined,
486+
"⏳", // filter pattern
487+
60, // long timeout
488+
true // filter_exclude = true
489+
),
490+
// Second call - still waiting, excluded lines don't wake us
491+
createBashOutputTool(
492+
"call-3",
493+
"bash_ci",
494+
"✅ All checks passed!\n\n🤖 Checking for unresolved Codex comments...\n\n✅ PR is ready to merge!",
495+
"exited",
496+
0,
497+
"⏳",
498+
60,
499+
true
500+
),
501+
],
502+
}
503+
),
504+
// Comparison: regular filter (include mode)
505+
createUserMessage("msg-5", "Show an example with regular filter", {
506+
historySequence: 5,
507+
timestamp: STABLE_TIMESTAMP - 100000,
508+
}),
509+
createAssistantMessage("msg-6", "Regular filter only shows matching lines:", {
510+
historySequence: 6,
511+
timestamp: STABLE_TIMESTAMP - 90000,
512+
toolCalls: [
513+
// Regular filter - include only ERROR lines
514+
createBashOutputTool(
515+
"call-4",
516+
"bash_ci",
517+
"ERROR: Build failed\nERROR: Test suite failed",
518+
"exited",
519+
1,
520+
"ERROR", // filter pattern (include mode)
521+
5,
522+
false // filter_exclude = false (default)
523+
),
524+
],
525+
}),
526+
],
527+
})
528+
}
529+
/>
530+
),
531+
play: async ({ canvasElement }) => {
532+
await expandAllBashTools(canvasElement);
533+
},
534+
};

src/browser/stories/mockFactory.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -398,14 +398,20 @@ export function createBashOutputTool(
398398
status: "running" | "exited" | "killed" | "failed" = "running",
399399
exitCode?: number,
400400
filter?: string,
401-
timeoutSecs = 5
401+
timeoutSecs = 5,
402+
filterExclude?: boolean
402403
): MuxPart {
403404
return {
404405
type: "dynamic-tool",
405406
toolCallId,
406407
toolName: "bash_output",
407408
state: "output-available",
408-
input: { process_id: processId, timeout_secs: timeoutSecs, filter },
409+
input: {
410+
process_id: processId,
411+
timeout_secs: timeoutSecs,
412+
filter,
413+
filter_exclude: filterExclude,
414+
},
409415
output: { success: true, status, output, exitCode },
410416
};
411417
}

src/common/types/tools.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ export interface StatusSetToolArgs {
230230
export interface BashOutputToolArgs {
231231
process_id: string;
232232
filter?: string;
233+
filter_exclude?: boolean;
233234
timeout_secs: number;
234235
}
235236

src/common/utils/tools/toolDefinitions.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,9 +251,21 @@ export const TOOL_DEFINITIONS = {
251251
.string()
252252
.optional()
253253
.describe(
254-
"Optional regex to filter output lines. Only matching lines are returned. " +
254+
"Optional regex to filter output lines. By default, only matching lines are returned. " +
255+
"When filter_exclude is true, matching lines are excluded instead. " +
255256
"Non-matching lines are permanently discarded and cannot be retrieved later."
256257
),
258+
filter_exclude: z
259+
.boolean()
260+
.optional()
261+
.describe(
262+
"When true, lines matching 'filter' are excluded instead of kept. " +
263+
"Key behavior: excluded lines do NOT cause early return from timeout - " +
264+
"waiting continues until non-excluded output arrives or process exits. " +
265+
"Use to avoid busy polling on progress spam (e.g., filter='⏳|waiting|\\.\\.\\.' with filter_exclude=true " +
266+
"lets you set a long timeout and only wake on meaningful output). " +
267+
"Requires 'filter' to be set."
268+
),
257269
timeout_secs: z
258270
.number()
259271
.min(0)

src/node/services/backgroundProcessManager.test.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ describe("BackgroundProcessManager", () => {
461461
if (!result.success) return;
462462

463463
// Wait with timeout to ensure blocking
464-
const output = await manager.getOutput(result.processId, undefined, 1);
464+
const output = await manager.getOutput(result.processId, undefined, undefined, 1);
465465
expect(output.success).toBe(true);
466466
if (!output.success) return;
467467

@@ -524,6 +524,119 @@ describe("BackgroundProcessManager", () => {
524524
expect(output.output).toContain("INFO: another");
525525
expect(output.output).not.toContain("DEBUG");
526526
});
527+
528+
it("should exclude matching lines when filter_exclude is true", async () => {
529+
const result = await manager.spawn(
530+
runtime,
531+
testWorkspaceId,
532+
"echo 'INFO: message'; echo 'DEBUG: noise'; echo 'INFO: another'",
533+
{ cwd: process.cwd(), displayName: "test" }
534+
);
535+
536+
expect(result.success).toBe(true);
537+
if (!result.success) return;
538+
539+
await new Promise((resolve) => setTimeout(resolve, 100));
540+
541+
// Exclude DEBUG lines (invert filter)
542+
const output = await manager.getOutput(result.processId, "DEBUG", true);
543+
expect(output.success).toBe(true);
544+
if (!output.success) return;
545+
546+
expect(output.output).toContain("INFO: message");
547+
expect(output.output).toContain("INFO: another");
548+
expect(output.output).not.toContain("DEBUG");
549+
});
550+
551+
it("should return error when filter_exclude is true but no filter provided", async () => {
552+
const result = await manager.spawn(runtime, testWorkspaceId, "echo hello", {
553+
cwd: process.cwd(),
554+
displayName: "test",
555+
});
556+
557+
expect(result.success).toBe(true);
558+
if (!result.success) return;
559+
560+
// filter_exclude without filter should error
561+
const output = await manager.getOutput(result.processId, undefined, true);
562+
expect(output.success).toBe(false);
563+
if (output.success) return;
564+
expect(output.error).toContain("filter_exclude requires filter");
565+
});
566+
567+
it("should keep waiting when only excluded lines arrive", async () => {
568+
// Script outputs progress spam for 300ms, then meaningful output
569+
const result = await manager.spawn(
570+
runtime,
571+
testWorkspaceId,
572+
"for i in 1 2 3; do echo 'PROGRESS'; sleep 0.1; done; echo 'DONE'",
573+
{ cwd: process.cwd(), displayName: "test" }
574+
);
575+
576+
expect(result.success).toBe(true);
577+
if (!result.success) return;
578+
579+
// With filter_exclude for PROGRESS, should wait until DONE arrives
580+
// Set timeout long enough to cover the full script duration
581+
const output = await manager.getOutput(result.processId, "PROGRESS", true, 2);
582+
expect(output.success).toBe(true);
583+
if (!output.success) return;
584+
585+
// Should only see DONE, not PROGRESS lines
586+
expect(output.output).toContain("DONE");
587+
expect(output.output).not.toContain("PROGRESS");
588+
// Should have waited ~300ms+ for meaningful output
589+
expect(output.elapsed_ms).toBeGreaterThanOrEqual(200);
590+
});
591+
592+
it("should return when process exits even if only excluded lines", async () => {
593+
// Script outputs ONLY excluded lines then exits
594+
const result = await manager.spawn(
595+
runtime,
596+
testWorkspaceId,
597+
"echo 'PROGRESS'; echo 'PROGRESS'; exit 0",
598+
{ cwd: process.cwd(), displayName: "test" }
599+
);
600+
601+
expect(result.success).toBe(true);
602+
if (!result.success) return;
603+
604+
// Wait for process to exit
605+
await new Promise((resolve) => setTimeout(resolve, 150));
606+
607+
// Should return (not hang) even though all output is excluded
608+
const output = await manager.getOutput(result.processId, "PROGRESS", true, 2);
609+
expect(output.success).toBe(true);
610+
if (!output.success) return;
611+
612+
// Output should be empty (all lines excluded), but we should have status
613+
expect(output.output.trim()).toBe("");
614+
expect(output.status).toBe("exited");
615+
});
616+
617+
it("should timeout and return even if only excluded lines arrived", async () => {
618+
// Script outputs progress indefinitely
619+
const result = await manager.spawn(
620+
runtime,
621+
testWorkspaceId,
622+
"while true; do echo 'PROGRESS'; sleep 0.1; done",
623+
{ cwd: process.cwd(), displayName: "test", timeoutSecs: 10 }
624+
);
625+
626+
expect(result.success).toBe(true);
627+
if (!result.success) return;
628+
629+
// Short timeout - should return with empty output, not hang
630+
const output = await manager.getOutput(result.processId, "PROGRESS", true, 0.3);
631+
expect(output.success).toBe(true);
632+
if (!output.success) return;
633+
634+
// Should have returned due to timeout, output empty (all excluded)
635+
expect(output.output.trim()).toBe("");
636+
expect(output.status).toBe("running");
637+
expect(output.elapsed_ms).toBeGreaterThanOrEqual(250);
638+
expect(output.elapsed_ms).toBeLessThan(1000); // Didn't hang
639+
});
527640
});
528641

529642
describe("integration: spawn and getOutput", () => {

0 commit comments

Comments
 (0)