Skip to content

Commit 3700190

Browse files
authored
🤖 feat: add polling detection to bash_output tool (#1098)
When agents call `bash_output` 3+ times on a running process without using `filter_exclude`, return a note instructing them to use `filter_exclude` with a longer timeout instead of polling repeatedly. ## Changes - Track `getOutputCallCount` per background process - Return a `note` field with guidance when polling detected (3+ calls, no filter_exclude, process still running) - Simplified tool description (runtime now handles the guidance dynamically) ## Example note returned ``` STOP POLLING. You've called bash_output 3+ times on this process. This wastes tokens and clutters the conversation. Instead, make ONE call with: filter='⏳|progress|waiting|\.\.\.', filter_exclude=true, timeout_secs=120. This blocks until meaningful output arrives. ``` ## Tests Added 3 tests covering: 1. Note appears after 3+ calls without filter_exclude on running process 2. Note does NOT appear when filter_exclude is used 3. Note does NOT appear when process has exited _Generated with `mux`_
1 parent 457297c commit 3700190

File tree

3 files changed

+97
-3
lines changed

3 files changed

+97
-3
lines changed

src/common/utils/tools/toolDefinitions.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,7 @@ export const TOOL_DEFINITIONS = {
244244
"Returns stdout and stderr output along with process status. " +
245245
"Supports optional regex filtering to show only lines matching a pattern. " +
246246
"WARNING: When using filter, non-matching lines are permanently discarded. " +
247-
"Use timeout to wait for output instead of polling repeatedly. " +
248-
"If you've called this 3+ times on the same process, use filter_exclude with a longer timeout instead.",
247+
"Use timeout to wait for output instead of polling repeatedly.",
249248
schema: z.object({
250249
process_id: z.string().describe("The ID of the background process to retrieve output from"),
251250
filter: z

src/node/services/backgroundProcessManager.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -870,4 +870,80 @@ describe("BackgroundProcessManager", () => {
870870
expect(exitCodeContent.trim()).toBe("42");
871871
});
872872
});
873+
874+
describe("polling detection", () => {
875+
it("should return note after 3+ calls without filter_exclude on running process", async () => {
876+
// Long-running process
877+
const result = await manager.spawn(
878+
runtime,
879+
testWorkspaceId,
880+
"while true; do echo 'tick'; sleep 0.5; done",
881+
{ cwd: process.cwd(), displayName: "test", timeoutSecs: 30 }
882+
);
883+
884+
expect(result.success).toBe(true);
885+
if (!result.success) return;
886+
887+
// First two calls should not have a note
888+
const output1 = await manager.getOutput(result.processId, undefined, undefined, 0.1);
889+
expect(output1.success).toBe(true);
890+
if (!output1.success) return;
891+
expect(output1.note).toBeUndefined();
892+
893+
const output2 = await manager.getOutput(result.processId, undefined, undefined, 0.1);
894+
expect(output2.success).toBe(true);
895+
if (!output2.success) return;
896+
expect(output2.note).toBeUndefined();
897+
898+
// Third call should have the suggestion note
899+
const output3 = await manager.getOutput(result.processId, undefined, undefined, 0.1);
900+
expect(output3.success).toBe(true);
901+
if (!output3.success) return;
902+
expect(output3.note).toContain("filter_exclude");
903+
expect(output3.note).toContain("3+ times");
904+
});
905+
906+
it("should NOT return note when filter_exclude is already used", async () => {
907+
const result = await manager.spawn(
908+
runtime,
909+
testWorkspaceId,
910+
"while true; do echo 'tick'; sleep 0.5; done",
911+
{ cwd: process.cwd(), displayName: "test", timeoutSecs: 30 }
912+
);
913+
914+
expect(result.success).toBe(true);
915+
if (!result.success) return;
916+
917+
// Make 3+ calls with filter_exclude
918+
for (let i = 0; i < 4; i++) {
919+
const output = await manager.getOutput(result.processId, "nomatch", true, 0.1);
920+
expect(output.success).toBe(true);
921+
if (!output.success) return;
922+
// Should never get the note since we're using filter_exclude
923+
expect(output.note).toBeUndefined();
924+
}
925+
});
926+
927+
it("should NOT return note when process has exited", async () => {
928+
const result = await manager.spawn(runtime, testWorkspaceId, "echo done; exit 0", {
929+
cwd: process.cwd(),
930+
displayName: "test",
931+
});
932+
933+
expect(result.success).toBe(true);
934+
if (!result.success) return;
935+
936+
// Wait for process to exit
937+
await new Promise((resolve) => setTimeout(resolve, 200));
938+
939+
// Make 3+ calls on exited process
940+
for (let i = 0; i < 4; i++) {
941+
const output = await manager.getOutput(result.processId, undefined, undefined, 0.1);
942+
expect(output.success).toBe(true);
943+
if (!output.success) return;
944+
// Should not get note since process is not running
945+
expect(output.note).toBeUndefined();
946+
}
947+
});
948+
});
873949
});

src/node/services/backgroundProcessManager.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export interface BackgroundProcess {
4444
/** Mutex to serialize getOutput() calls (prevents race condition when
4545
* parallel tool calls read from same offset before position is updated) */
4646
outputLock: AsyncMutex;
47+
/** Tracks how many times getOutput() has been called (for polling detection) */
48+
getOutputCallCount: number;
4749
}
4850

4951
/**
@@ -219,6 +221,7 @@ export class BackgroundProcessManager extends EventEmitter<BackgroundProcessMana
219221
isForeground: config.isForeground ?? false,
220222
outputBytesRead: 0,
221223
outputLock: new AsyncMutex(),
224+
getOutputCallCount: 0,
222225
};
223226

224227
// Store process in map
@@ -327,6 +330,7 @@ export class BackgroundProcessManager extends EventEmitter<BackgroundProcessMana
327330
isForeground: false, // Now in background
328331
outputBytesRead: 0,
329332
outputLock: new AsyncMutex(),
333+
getOutputCallCount: 0,
330334
};
331335

332336
// Store process in map
@@ -458,6 +462,7 @@ export class BackgroundProcessManager extends EventEmitter<BackgroundProcessMana
458462
output: string;
459463
exitCode?: number;
460464
elapsed_ms: number;
465+
note?: string;
461466
}
462467
| { success: false; error: string }
463468
> {
@@ -481,8 +486,12 @@ export class BackgroundProcessManager extends EventEmitter<BackgroundProcessMana
481486
// the same offset before either updates the read position.
482487
await using _lock = await proc.outputLock.acquire();
483488

489+
// Track call count for polling detection
490+
proc.getOutputCallCount++;
491+
const callCount = proc.getOutputCallCount;
492+
484493
log.debug(
485-
`BackgroundProcessManager.getOutput: proc.outputDir=${proc.outputDir}, offset=${proc.outputBytesRead}`
494+
`BackgroundProcessManager.getOutput: proc.outputDir=${proc.outputDir}, offset=${proc.outputBytesRead}, callCount=${callCount}`
486495
);
487496

488497
// Pre-compile regex if filter is provided
@@ -564,6 +573,10 @@ export class BackgroundProcessManager extends EventEmitter<BackgroundProcessMana
564573

565574
const filteredOutput = applyFilter(accumulatedRaw);
566575

576+
// Suggest filter_exclude if polling too frequently on a running process
577+
const shouldSuggestFilterExclude =
578+
callCount >= 3 && !filterExclude && currentStatus === "running";
579+
567580
return {
568581
success: true,
569582
status: currentStatus,
@@ -573,6 +586,12 @@ export class BackgroundProcessManager extends EventEmitter<BackgroundProcessMana
573586
? ((await this.getProcess(processId))?.exitCode ?? undefined)
574587
: undefined,
575588
elapsed_ms: Date.now() - startTime,
589+
note: shouldSuggestFilterExclude
590+
? "STOP POLLING. You've called bash_output 3+ times on this process. " +
591+
"This wastes tokens and clutters the conversation. " +
592+
"Instead, make ONE call with: filter='⏳|progress|waiting|\\\\.\\\\.\\\\.', " +
593+
"filter_exclude=true, timeout_secs=120. This blocks until meaningful output arrives."
594+
: undefined,
576595
};
577596
}
578597

0 commit comments

Comments
 (0)