feat(coding-agent-async-tooling): added poll tool and async prompts

- Replaced deprecated `await` tool wiring with `poll` in tool exports and built-in tool registry.
- Updated bash and task prompts plus start-result messaging to direct users to the `poll` tool.
- Added effective timeout metadata to Bash tool results and rendered output to show that effective timeout.
- Implemented bounded auto-background wait logic that backgrounds jobs when the timeout window is exhausted.
This commit is contained in:
can1357
2026-04-13 13:04:12 +02:00
parent 779cf2eb4a
commit 939501f624
9 changed files with 124 additions and 47 deletions
@@ -24,10 +24,10 @@ Executes bash command in shell session for terminal operations like git, bun, ca
{{/if}}
{{#if asyncEnabled}}
- Use `read jobs://` to inspect all background jobs and `read jobs://<job-id>` for detailed status/output when needed.
- When you need to wait for async results before continuing, call `await` — it blocks until jobs complete. Do NOT poll `read jobs://` in a loop or yield and hope for delivery.
- When you need to wait for async results before continuing, call `poll` — it blocks until jobs complete. Do NOT poll `read jobs://` in a loop or yield and hope for delivery.
{{else}}
{{#if autoBackgroundEnabled}}
- If a command auto-backgrounds, use `read jobs://` to inspect jobs and `await` when you need to wait for completion instead of polling in a loop.
- If a command auto-backgrounds, use `read jobs://` to inspect jobs and `poll` when you need to wait for completion instead of polling in a loop.
{{/if}}
{{/if}}
</instruction>
@@ -1,5 +1,5 @@
Blocks until one or more background jobs complete, fail, or are cancelled.
You **MUST** use this instead of polling `read jobs://` in a loop when you need to wait for background task or bash results before continuing.
You **MUST** use the `poll` tool instead of polling `read jobs://` in a loop when you need to wait for background task or bash results before continuing.
Returns the status and results of all watched jobs once at least one finishes.
@@ -2,7 +2,7 @@ Launches subagents to parallelize workflows.
{{#if asyncEnabled}}
- Use `read jobs://` to inspect state; `read jobs://<job_id>` for detail.
- Use the `await` tool to wait until completion. You **MUST NOT** poll `read jobs://` in a loop.
- Use the `poll` tool to wait until completion. You **MUST NOT** poll `read jobs://` in a loop.
{{/if}}
Subagents lack your conversation history. Every decision, file content, and user requirement they need **MUST** be explicit in `context` or `assignment`.
+26 -8
View File
@@ -73,6 +73,7 @@ export interface BashToolInput {
export interface BashToolDetails {
meta?: OutputMeta;
timeoutSeconds?: number;
async?: {
state: "running" | "completed" | "failed";
jobId: string;
@@ -290,14 +291,20 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
tailLines?: number,
): AgentToolResult<BashToolDetails> {
const outputText = this.#formatResultOutput(result, headLines, tailLines);
const details: BashToolDetails = {};
const details: BashToolDetails = { timeoutSeconds: timeoutSec };
const resultBuilder = toolResult(details).text(outputText).truncationFromSummary(result, { direction: "tail" });
this.#buildResultText(result, timeoutSec, outputText);
return resultBuilder.done();
}
#buildBackgroundStartResult(jobId: string, label: string, previewText: string): AgentToolResult<BashToolDetails> {
#buildBackgroundStartResult(
jobId: string,
label: string,
previewText: string,
timeoutSec: number,
): AgentToolResult<BashToolDetails> {
const details: BashToolDetails = {
timeoutSeconds: timeoutSec,
async: { state: "running", jobId, type: "bash" },
};
const lines: string[] = [];
@@ -307,7 +314,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
}
lines.push(`Background job ${jobId} started: ${label}`);
lines.push("Result will be delivered automatically when complete.");
lines.push(`Use \`await\`, \`read jobs://${jobId}\`, or \`cancel_job\` if needed.`);
lines.push(`Use \`poll\`, \`read jobs://${jobId}\`, or \`cancel_job\` if needed.`);
return {
content: [{ type: "text", text: lines.join("\n") }],
details,
@@ -430,6 +437,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
}
}
#resolveAutoBackgroundWaitMs(timeoutMs: number): number {
if (this.#autoBackgroundThresholdMs <= 0) return 0;
const timeoutBufferMs = 1_000;
return Math.max(0, Math.min(this.#autoBackgroundThresholdMs, timeoutMs - timeoutBufferMs));
}
async execute(
_toolCallId: string,
{
@@ -536,10 +549,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
onUpdate,
startBackgrounded: true,
});
return this.#buildBackgroundStartResult(job.jobId, job.label, "");
return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec);
}
if (this.#autoBackgroundEnabled && !pty && this.session.asyncJobManager) {
const autoBackgroundWaitMs = this.#resolveAutoBackgroundWaitMs(timeoutMs);
const startBackgrounded = autoBackgroundWaitMs === 0;
const job = this.#startManagedBashJob({
command,
commandCwd,
@@ -549,9 +564,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
tailLines,
resolvedEnv,
onUpdate,
startBackgrounded: false,
startBackgrounded,
});
const waitResult = await this.#waitForManagedBashJob(job, this.#autoBackgroundThresholdMs, signal);
if (startBackgrounded) {
return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec);
}
const waitResult = await this.#waitForManagedBashJob(job, autoBackgroundWaitMs, signal);
if (waitResult.kind === "completed") {
this.session.asyncJobManager.acknowledgeDeliveries([job.jobId]);
return waitResult.result;
@@ -566,7 +584,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
throw new ToolAbortError(job.getLatestText() || "Command aborted");
}
job.setBackgrounded(true);
return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText());
return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec);
}
// Track output for streaming updates (tail only)
@@ -689,7 +707,7 @@ export const bashToolRenderer = {
const showingFullOutput = expanded && renderContext?.isFullOutput === true;
// Build truncation warning
const timeoutSeconds = renderContext?.timeout;
const timeoutSeconds = details?.timeoutSeconds ?? renderContext?.timeout;
const timeoutLine =
typeof timeoutSeconds === "number"
? uiTheme.fg(
+3 -3
View File
@@ -23,7 +23,7 @@ import { SearchTool } from "../web/search";
import { AskTool } from "./ask";
import { AstEditTool } from "./ast-edit";
import { AstGrepTool } from "./ast-grep";
import { AwaitTool } from "./await-tool";
import { PollTool } from "./poll-tool";
import { BashTool } from "./bash";
import { BrowserTool } from "./browser";
@@ -73,7 +73,7 @@ export * from "../web/search";
export * from "./ask";
export * from "./ast-edit";
export * from "./ast-grep";
export * from "./await-tool";
export * from "./poll-tool";
export * from "./bash";
export * from "./browser";
export * from "./calculator";
@@ -243,7 +243,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
rewind: RewindTool.createIf,
task: TaskTool.create,
cancel_job: CancelJobTool.createIf,
await: AwaitTool.createIf,
poll: PollTool.createIf,
todo_write: s => new TodoWriteTool(s),
web_search: s => new SearchTool(s),
search_tool_bm25: SearchToolBm25Tool.createIf,
@@ -2,10 +2,10 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
import { prompt } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import { isBackgroundJobSupportEnabled } from "../async";
import awaitDescription from "../prompts/tools/await.md" with { type: "text" };
import pollDescription from "../prompts/tools/poll.md" with { type: "text" };
import type { ToolSession } from "./index";
const awaitSchema = Type.Object({
const pollSchema = Type.Object({
jobs: Type.Optional(
Type.Array(Type.String(), {
description: "Specific job IDs to wait for. If omitted, waits for any running job.",
@@ -13,9 +13,9 @@ const awaitSchema = Type.Object({
),
});
type AwaitParams = Static<typeof awaitSchema>;
type PollParams = Static<typeof pollSchema>;
interface AwaitResult {
interface PollResult {
id: string;
type: "bash" | "task";
status: "running" | "completed" | "failed" | "cancelled";
@@ -25,33 +25,33 @@ interface AwaitResult {
errorText?: string;
}
export interface AwaitToolDetails {
jobs: AwaitResult[];
export interface PollToolDetails {
jobs: PollResult[];
}
export class AwaitTool implements AgentTool<typeof awaitSchema, AwaitToolDetails> {
readonly name = "await";
readonly label = "Await";
export class PollTool implements AgentTool<typeof pollSchema, PollToolDetails> {
readonly name = "poll";
readonly label = "Poll";
readonly description: string;
readonly parameters = awaitSchema;
readonly parameters = pollSchema;
readonly strict = true;
constructor(private readonly session: ToolSession) {
this.description = prompt.render(awaitDescription);
this.description = prompt.render(pollDescription);
}
static createIf(session: ToolSession): AwaitTool | null {
static createIf(session: ToolSession): PollTool | null {
if (!isBackgroundJobSupportEnabled(session.settings)) return null;
return new AwaitTool(session);
return new PollTool(session);
}
async execute(
_toolCallId: string,
params: AwaitParams,
params: PollParams,
signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<AwaitToolDetails>,
_onUpdate?: AgentToolUpdateCallback<PollToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<AwaitToolDetails>> {
): Promise<AgentToolResult<PollToolDetails>> {
const manager = this.session.asyncJobManager;
if (!manager) {
return {
@@ -124,12 +124,12 @@ export class AwaitTool implements AgentTool<typeof awaitSchema, AwaitToolDetails
resultText?: string;
errorText?: string;
}[],
): AgentToolResult<AwaitToolDetails> {
): AgentToolResult<PollToolDetails> {
const now = Date.now();
const jobResults: AwaitResult[] = jobs.map(j => ({
const jobResults: PollResult[] = jobs.map(j => ({
id: j.id,
type: j.type,
status: j.status as AwaitResult["status"],
status: j.status as PollResult["status"],
label: j.label,
durationMs: Math.max(0, now - j.startTime),
...(j.resultText ? { resultText: j.resultText } : {}),
@@ -105,7 +105,7 @@ describe("executeBash", () => {
return;
}
const result = await executeBash("sleep 10 & echo $!", {
const result = await executeBash('python3 -c "import time; time.sleep(10)" & echo $!', {
cwd: tempDir,
timeout: 5000,
});
@@ -113,9 +113,7 @@ describe("executeBash", () => {
expect(Number.isInteger(pid)).toBe(true);
expect(pid).toBeGreaterThan(0);
expect(() => process.kill(pid, 0)).not.toThrow();
process.kill(pid, "SIGTERM");
await Bun.sleep(100);
expect(() => process.kill(pid, 0)).toThrow();
expect(() => process.kill(pid, "SIGKILL")).not.toThrow();
});
+55 -9
View File
@@ -10,7 +10,7 @@ import { DEFAULT_BASH_INTERCEPTOR_RULES, Settings } from "@oh-my-pi/pi-coding-ag
import { EditTool } from "@oh-my-pi/pi-coding-agent/edit";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { AwaitTool } from "@oh-my-pi/pi-coding-agent/tools/await-tool";
import { PollTool } from "@oh-my-pi/pi-coding-agent/tools/poll-tool";
import { BashTool } from "@oh-my-pi/pi-coding-agent/tools/bash";
import { CancelJobTool } from "@oh-my-pi/pi-coding-agent/tools/cancel-job";
import { FindTool } from "@oh-my-pi/pi-coding-agent/tools/find";
@@ -790,7 +790,7 @@ function b() {
const result = await bashTool.execute("test-call-8", { command: "echo 'test output'" });
expect(getTextOutput(result)).toContain("test output");
expect(result.details).toBeUndefined();
expect(result.details?.timeoutSeconds).toBe(300);
});
it("should expose built-in interceptor defaults truthfully", () => {
@@ -989,7 +989,8 @@ function b() {
const result = await autoBackgroundBashTool.execute("test-call-9-auto-inline", { command: "echo short" });
expect(getTextOutput(result)).toContain("short");
expect(result.details).toBeUndefined();
expect(result.details?.timeoutSeconds).toBe(300);
expect(result.details?.async).toBeUndefined();
await Bun.sleep(150);
expect(deliveries).toEqual([]);
await asyncJobManager.dispose();
@@ -1041,6 +1042,51 @@ function b() {
await asyncJobManager.dispose();
});
it("should background instead of timing out when auto-background wait exceeds the effective timeout", async () => {
const deliveries: Array<{ jobId: string; text: string }> = [];
const asyncJobManager = new AsyncJobManager({
onJobComplete: async (jobId, text) => {
deliveries.push({ jobId, text });
},
});
const autoBackgroundBashTool = wrapToolWithMetaNotice(
new BashTool(
createTestToolSession(
testDir,
Settings.isolated({
"bash.autoBackground.enabled": true,
"bash.autoBackground.thresholdMs": 60_000,
}),
{
asyncJobManager,
getSessionId: () => "test-session",
},
),
),
);
const result = await autoBackgroundBashTool.execute("test-call-9-auto-timeout-background", {
command: "printf 'start\\n'; sleep 1.2; printf 'done\\n'",
timeout: 1,
});
expect(result.details?.timeoutSeconds).toBe(1);
expect(result.details?.async?.state).toBe("running");
expect(getTextOutput(result)).toContain("Background job");
const jobId = result.details?.async?.jobId;
if (!jobId) {
throw new Error("expected an auto-backgrounded job id");
}
const runningJob = asyncJobManager.getJob(jobId);
expect(runningJob?.status).toBe("running");
await runningJob?.promise;
await Bun.sleep(50);
expect(deliveries).toHaveLength(1);
expect(deliveries[0]?.jobId).toBe(jobId);
expect(deliveries[0]?.text).toContain("Command timed out after 1 seconds");
await asyncJobManager.dispose();
});
it("should respect timeout", async () => {
await expect(bashTool.execute("test-call-10", { command: "sleep 5", timeout: 1 })).rejects.toThrow(
/timed out/i,
@@ -1074,12 +1120,12 @@ function b() {
Settings.isolated({ "bash.autoBackground.enabled": true }),
);
expect(AwaitTool.createIf(autoBackgroundSession)).not.toBeNull();
expect(PollTool.createIf(autoBackgroundSession)).not.toBeNull();
expect(CancelJobTool.createIf(autoBackgroundSession)).not.toBeNull();
});
});
describe("AwaitTool", () => {
describe("PollTool", () => {
it("should wait for jobs and acknowledge deliveries to prevent race conditions", async () => {
const manager = new AsyncJobManager({
onJobComplete: async () => {},
@@ -1087,14 +1133,14 @@ function b() {
const session = createTestToolSession(testDir, Settings.isolated({ "bash.autoBackground.enabled": true }), {
asyncJobManager: manager,
});
const awaitTool = AwaitTool.createIf(session)!;
const pollTool = PollTool.createIf(session)!;
const jobId = manager.register("bash", "test job", async () => "success");
// Job is running, call await
const resultPromise = awaitTool.execute("test-call-await-1", { jobs: [jobId] });
// Job is running, call poll
const resultPromise = pollTool.execute("test-call-poll-1", { jobs: [jobId] });
// Ensure await finished
// Ensure poll finished
const result = await resultPromise;
expect(getTextOutput(result)).toContain("Completed");
@@ -69,6 +69,21 @@ describe("bashToolRenderer", () => {
expect(rendered).not.toContain("\t");
});
it("shows the effective timeout from result details when it differs from call args", async () => {
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const component = bashToolRenderer.renderResult(
{ content: [{ type: "text", text: "" }], details: { timeoutSeconds: 120 }, isError: false },
{ expanded: false, isPartial: false, renderContext: { timeout: 1200 } },
uiTheme,
{ command: "python3 scripts/vim-edit-benchmark.py", timeout: 1200 },
);
const rendered = sanitizeText(component.render(120).join("\n"));
expect(rendered).toContain("Timeout: 120s");
expect(rendered).not.toContain("Timeout: 1200s");
});
it("bypasses truncation/styling for SIXEL lines", async () => {
terminal.imageProtocol = ImageProtocol.Sixel;
const theme = await getThemeByName("dark");