feat(coding-agent): added auto-backgrounding for long bash jobs to avoid blocking

- Added bash.autoBackground.enabled and bash.autoBackground.thresholdMs settings with defaults for background job behavior.
- Added auto-backgrounding support for long foreground bash commands with managed job state and timeout threshold.
- Updated bash prompts, job-protocol messages, and tool activation checks to use async and auto-background support.
- Added background bash completion integration with AsyncJobManager and end-to-end tests for short/long auto-background scenarios.
This commit is contained in:
can1357
2026-04-11 11:05:16 +02:00
parent ae85ed4cc7
commit 23a5afe8d9
14 changed files with 380 additions and 65 deletions
+3
View File
@@ -14,6 +14,8 @@
### Changed
- Enabled `await` and `cancel_job` to be available when `bash.autoBackground.enabled` is set, so auto-backgrounded bash jobs can be awaited or cancelled without enabling `async.enabled`
- Updated bash auto-background behavior so short commands returned inline output when they completed before the configured threshold, while longer runs moved to background jobs automatically
- Replaced the LLM-callable Python execution path with JavaScript execution in the shared VM context, including updated renderers, prompts, session messages, and extension events
- Updated interactive and CLI model listings/selectors to work with canonical model ids while resolving them to concrete provider variants for actual execution
- Updated role assignment persistence so selected model settings now store the selector used by users, including thinking-level suffixes, while runtime continues to run against the resolved concrete provider model
@@ -21,6 +23,7 @@
- Changed the agent to avoid giving time estimates or task-duration predictions in user responses, focusing on required work instead
- Changed generated code guidance to avoid speculative abstractions and extra compatibility scaffolding, favoring direct implementations that match current needs
- Changed model role resolution so roles can store either canonical model ids or explicit `provider/model` selectors while sessions continue to record the concrete model actually used
- Updated bash execution to optionally auto-background long-running commands through the existing background-job pipeline, with dedicated settings for enabling the behavior and adjusting the delay
### Fixed
+1
View File
@@ -1 +1,2 @@
export * from "./job-manager";
export * from "./support";
@@ -0,0 +1,5 @@
import type { Settings } from "../config/settings";
export function isBackgroundJobSupportEnabled(settings: Pick<Settings, "get">): boolean {
return settings.get("async.enabled") || settings.get("bash.autoBackground.enabled");
}
@@ -1385,6 +1385,27 @@ export const SETTINGS_SCHEMA = {
},
},
"bash.autoBackground.enabled": {
type: "boolean",
default: false,
ui: {
tab: "tools",
label: "Bash Auto-Background",
description: "Automatically background long-running bash commands and deliver the result later",
},
},
"bash.autoBackground.thresholdMs": {
type: "number",
default: 60_000,
ui: {
tab: "tools",
label: "Bash Auto-Background Delay",
description: "Milliseconds to wait before a bash command is moved to the background (0 = immediately)",
submenu: true,
},
},
// MCP
"mcp.enableProjectConfig": {
type: "boolean",
@@ -30,7 +30,8 @@ export class JobsProtocolHandler implements ProtocolHandler {
async resolve(url: InternalUrl): Promise<InternalResource> {
const manager = this.options.getAsyncJobManager();
if (!manager) {
const content = "# Jobs\n\nAsync execution is disabled. Enable `async.enabled` to use jobs://.";
const content =
"# Jobs\n\nBackground job support is disabled. Enable `async.enabled` or `bash.autoBackground.enabled` to use jobs://.";
return {
url: url.href,
content,
+2
View File
@@ -79,6 +79,8 @@ const RPC_DEFAULTED_SETTING_PATHS: SettingPath[] = [
"todo.eager",
"async.enabled",
"async.maxJobs",
"bash.autoBackground.enabled",
"bash.autoBackground.thresholdMs",
"task.isolation.mode",
"task.isolation.merge",
"task.isolation.commits",
@@ -12,8 +12,23 @@ Executes bash command in shell session for terminal operations like git, bun, ca
- Internal URLs are also auto-resolved to filesystem paths before execution.
{{#if asyncEnabled}}
- Use `async: true` for long-running commands when you don't need immediate output; the call returns a background job ID and the result is delivered automatically as a follow-up.
{{/if}}
{{#if autoBackgroundEnabled}}
- Long-running non-PTY bash commands may auto-background after about {{autoBackgroundThresholdSeconds}}s and continue as background jobs automatically.
{{/if}}
{{#if asyncEnabled}}
{{else}}
{{#if autoBackgroundEnabled}}
- Auto-backgrounded jobs use the same background-job pipeline as explicit async execution.
{{/if}}
{{/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.
{{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}}
{{/if}}
</instruction>
@@ -1,4 +1,4 @@
Cancels a running background job started via async tool execution.
Cancels a running background job started via async tool execution or bash auto-backgrounding.
You **SHOULD** use this when a background `bash` or `task` job is no longer needed or is stuck.
+3 -3
View File
@@ -25,7 +25,7 @@ import {
prompt,
} from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { AsyncJobManager } from "./async";
import { AsyncJobManager, isBackgroundJobSupportEnabled } from "./async";
import { createAutoresearchExtension } from "./autoresearch";
import { loadCapability } from "./capability";
import { type Rule, ruleCapability } from "./capability/rule";
@@ -838,7 +838,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
let session: AgentSession;
const enableLsp = options.enableLsp ?? true;
const asyncEnabled = settings.get("async.enabled");
const backgroundJobsEnabled = isBackgroundJobSupportEnabled(settings);
const asyncMaxJobs = Math.min(100, Math.max(1, settings.get("async.maxJobs") ?? 100));
const ASYNC_INLINE_RESULT_MAX_CHARS = 12_000;
const ASYNC_PREVIEW_MAX_CHARS = 4_000;
@@ -862,7 +862,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
return preview;
};
const asyncJobManager = asyncEnabled
const asyncJobManager = backgroundJobsEnabled
? new AsyncJobManager({
maxRunningJobs: asyncMaxJobs,
onJobComplete: async (jobId, result, job) => {
+5 -1
View File
@@ -435,7 +435,11 @@ function createSubagentSettings(baseSettings: Settings): Settings {
for (const key of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
snapshot[key] = baseSettings.get(key);
}
return Settings.isolated({ ...snapshot, "async.enabled": false });
return Settings.isolated({
...snapshot,
"async.enabled": false,
"bash.autoBackground.enabled": false,
});
}
/**
@@ -1,6 +1,7 @@
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
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 type { ToolSession } from "./index";
@@ -40,7 +41,7 @@ export class AwaitTool implements AgentTool<typeof awaitSchema, AwaitToolDetails
}
static createIf(session: ToolSession): AwaitTool | null {
if (!session.settings.get("async.enabled")) return null;
if (!isBackgroundJobSupportEnabled(session.settings)) return null;
return new AwaitTool(session);
}
+221 -56
View File
@@ -28,6 +28,7 @@ import { clampTimeout } from "./tool-timeouts";
export const BASH_DEFAULT_PREVIEW_LINES = 10;
const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
const bashSchemaBase = Type.Object({
command: Type.String({ description: "Command to execute" }),
@@ -81,6 +82,24 @@ export interface BashToolDetails {
export interface BashToolOptions {}
type ManagedBashJobCompletion =
| {
kind: "completed";
result: AgentToolResult<BashToolDetails>;
}
| {
kind: "failed";
error: unknown;
};
interface ManagedBashJobHandle {
jobId: string;
label: string;
completion: Promise<ManagedBashJobCompletion>;
getLatestText: () => string;
setBackgrounded: (backgrounded: boolean) => void;
}
function normalizeResultOutput(result: BashResult | BashInteractiveResult): string {
return result.output || "";
}
@@ -212,12 +231,23 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
readonly concurrency = "exclusive";
readonly strict = true;
readonly #asyncEnabled: boolean;
readonly #autoBackgroundEnabled: boolean;
readonly #autoBackgroundThresholdMs: number;
constructor(private readonly session: ToolSession) {
this.#asyncEnabled = this.session.settings.get("async.enabled");
this.#autoBackgroundEnabled = this.session.settings.get("bash.autoBackground.enabled");
this.#autoBackgroundThresholdMs = Math.max(
0,
Math.floor(
this.session.settings.get("bash.autoBackground.thresholdMs") ?? DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS,
),
);
this.parameters = this.#asyncEnabled ? bashSchemaWithAsync : bashSchemaBase;
this.description = prompt.render(bashDescription, {
asyncEnabled: this.#asyncEnabled,
autoBackgroundEnabled: this.#autoBackgroundEnabled,
autoBackgroundThresholdSeconds: Math.max(0, Math.floor(this.#autoBackgroundThresholdMs / 1000)),
hasAstGrep: this.session.settings.get("astGrep.enabled"),
hasAstEdit: this.session.settings.get("astEdit.enabled"),
hasGrep: this.session.settings.get("grep.enabled"),
@@ -253,6 +283,153 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
return outputText;
}
#buildCompletedResult(
result: BashResult | BashInteractiveResult,
timeoutSec: number,
headLines?: number,
tailLines?: number,
): AgentToolResult<BashToolDetails> {
const outputText = this.#formatResultOutput(result, headLines, tailLines);
const details: BashToolDetails = {};
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> {
const details: BashToolDetails = {
async: { state: "running", jobId, type: "bash" },
};
const lines: string[] = [];
const trimmedPreview = previewText.trimEnd();
if (trimmedPreview.length > 0) {
lines.push(trimmedPreview, "");
}
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.`);
return {
content: [{ type: "text", text: lines.join("\n") }],
details,
};
}
#extractTextResult(result: AgentToolResult<BashToolDetails>): string {
return result.content.find(block => block.type === "text")?.text ?? "";
}
#startManagedBashJob(options: {
command: string;
commandCwd: string;
timeoutMs: number;
timeoutSec: number;
headLines?: number;
tailLines?: number;
resolvedEnv?: Record<string, string>;
onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
startBackgrounded: boolean;
}): ManagedBashJobHandle {
const manager = this.session.asyncJobManager;
if (!manager) {
throw new ToolError("Background job manager unavailable for this session.");
}
const label = options.command.length > 120 ? `${options.command.slice(0, 117)}...` : options.command;
let latestText = "";
let backgrounded = options.startBackgrounded;
const completion = Promise.withResolvers<ManagedBashJobCompletion>();
const jobId = manager.register(
"bash",
label,
async ({ jobId, signal: runSignal, reportProgress }) => {
const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("bash")) ?? {};
const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES);
try {
const result = await executeBash(options.command, {
cwd: options.commandCwd,
sessionKey: `${this.session.getSessionId?.() ?? ""}:async:${jobId}`,
timeout: options.timeoutMs,
signal: runSignal,
env: options.resolvedEnv,
artifactPath,
artifactId,
onChunk: chunk => {
tailBuffer.append(chunk);
latestText = tailBuffer.text();
void reportProgress(latestText, { async: { state: "running", jobId, type: "bash" } });
},
});
const finalResult = this.#buildCompletedResult(
result,
options.timeoutSec,
options.headLines,
options.tailLines,
);
const finalText = this.#extractTextResult(finalResult);
latestText = finalText;
completion.resolve({ kind: "completed", result: finalResult });
await reportProgress(finalText, { async: { state: "completed", jobId, type: "bash" } });
return finalText;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
latestText = message;
completion.resolve({ kind: "failed", error });
await reportProgress(message, { async: { state: "failed", jobId, type: "bash" } });
throw error;
}
},
{
onProgress: async (text, details) => {
latestText = text;
await options.onUpdate?.({
content: [{ type: "text", text }],
details: backgrounded ? ((details ?? {}) as BashToolDetails) : {},
});
},
},
);
return {
jobId,
label,
completion: completion.promise,
getLatestText: () => latestText,
setBackgrounded: (nextBackgrounded: boolean) => {
backgrounded = nextBackgrounded;
},
};
}
async #waitForManagedBashJob(
job: ManagedBashJobHandle,
thresholdMs: number,
signal?: AbortSignal,
): Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" }> {
if (signal?.aborted) {
return { kind: "aborted" };
}
const waiters: Array<Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" }>> = [
job.completion,
Bun.sleep(thresholdMs).then(() => ({ kind: "running" as const })),
];
if (!signal) {
return await Promise.race(waiters);
}
const { promise: abortedPromise, resolve: resolveAborted } = Promise.withResolvers<{ kind: "aborted" }>();
const onAbort = () => resolveAborted({ kind: "aborted" });
signal.addEventListener("abort", onAbort, { once: true });
waiters.push(abortedPromise);
try {
return await Promise.race(waiters);
} finally {
signal.removeEventListener("abort", onAbort);
}
}
async execute(
_toolCallId: string,
{
@@ -345,52 +522,51 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
const timeoutMs = timeoutSec * 1000;
if (asyncRequested) {
const manager = this.session.asyncJobManager;
if (!manager) {
if (!this.session.asyncJobManager) {
throw new ToolError("Async job manager unavailable for this session.");
}
const label = command.length > 120 ? `${command.slice(0, 117)}...` : command;
const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES);
const jobId = manager.register(
"bash",
label,
async ({ jobId, signal: runSignal, reportProgress }) => {
const { path: artifactPath, id: artifactId } =
(await this.session.allocateOutputArtifact?.("bash")) ?? {};
try {
const result = await executeBash(command, {
cwd: commandCwd,
sessionKey: `${this.session.getSessionId?.() ?? ""}:async:${jobId}`,
timeout: timeoutMs,
signal: runSignal,
env: resolvedEnv,
artifactPath,
artifactId,
onChunk: chunk => {
tailBuffer.append(chunk);
void reportProgress(tailBuffer.text(), { async: { state: "running", jobId, type: "bash" } });
},
});
const outputText = this.#formatResultOutput(result, headLines, tailLines);
const finalText = this.#buildResultText(result, timeoutSec, outputText);
await reportProgress(finalText, { async: { state: "completed", jobId, type: "bash" } });
return finalText;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await reportProgress(message, { async: { state: "failed", jobId, type: "bash" } });
throw error;
}
},
{
onProgress: (text, details) => {
onUpdate?.({ content: [{ type: "text", text }], details: details ?? {} });
},
},
);
return {
content: [{ type: "text", text: `Background job ${jobId} started: ${label}` }],
details: { async: { state: "running", jobId, type: "bash" } },
};
const job = this.#startManagedBashJob({
command,
commandCwd,
timeoutMs,
timeoutSec,
headLines,
tailLines,
resolvedEnv,
onUpdate,
startBackgrounded: true,
});
return this.#buildBackgroundStartResult(job.jobId, job.label, "");
}
if (this.#autoBackgroundEnabled && !pty && this.session.asyncJobManager) {
const job = this.#startManagedBashJob({
command,
commandCwd,
timeoutMs,
timeoutSec,
headLines,
tailLines,
resolvedEnv,
onUpdate,
startBackgrounded: false,
});
const waitResult = await this.#waitForManagedBashJob(job, this.#autoBackgroundThresholdMs, signal);
if (waitResult.kind === "completed") {
this.session.asyncJobManager.acknowledgeDeliveries([job.jobId]);
return waitResult.result;
}
if (waitResult.kind === "failed") {
this.session.asyncJobManager.acknowledgeDeliveries([job.jobId]);
throw waitResult.error;
}
if (waitResult.kind === "aborted") {
this.session.asyncJobManager.cancel(job.jobId);
this.session.asyncJobManager.acknowledgeDeliveries([job.jobId]);
throw new ToolAbortError(job.getLatestText() || "Command aborted");
}
job.setBackgrounded(true);
return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText());
}
// Track output for streaming updates (tail only)
@@ -437,18 +613,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
if (isInteractiveResult(result) && result.timedOut) {
throw new ToolError(normalizeResultOutput(result) || `Command timed out after ${timeoutSec} seconds`);
}
const outputText = this.#formatResultOutput(result, headLines, tailLines);
const details: BashToolDetails = {};
const resultBuilder = toolResult(details).text(outputText).truncationFromSummary(result, { direction: "tail" });
if (result.exitCode === undefined) {
throw new ToolError(`${outputText}\n\nCommand failed: missing exit status`);
}
if (result.exitCode !== 0 && result.exitCode !== undefined) {
throw new ToolError(`${outputText}\n\nCommand exited with code ${result.exitCode}`);
}
return resultBuilder.done();
return this.#buildCompletedResult(result, timeoutSec, headLines, tailLines);
}
}
@@ -1,6 +1,7 @@
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { prompt } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import { isBackgroundJobSupportEnabled } from "../async";
import cancelJobDescription from "../prompts/tools/cancel-job.md" with { type: "text" };
import type { ToolSession } from "./index";
@@ -27,7 +28,7 @@ export class CancelJobTool implements AgentTool<typeof cancelJobSchema, CancelJo
}
static createIf(session: ToolSession): CancelJobTool | null {
if (!session.settings.get("async.enabled")) return null;
if (!isBackgroundJobSupportEnabled(session.settings)) return null;
return new CancelJobTool(session);
}
+97 -1
View File
@@ -5,11 +5,14 @@ import * as path from "node:path";
import * as url from "node:url";
import * as zlib from "node:zlib";
import type { AgentToolContext } from "@oh-my-pi/pi-agent-core";
import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async";
import { DEFAULT_BASH_INTERCEPTOR_RULES, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
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 { 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";
import { GrepTool } from "@oh-my-pi/pi-coding-agent/tools/grep";
import { wrapToolWithMetaNotice } from "@oh-my-pi/pi-coding-agent/tools/output-meta";
@@ -171,7 +174,11 @@ function createZipArchive(entries: ArchiveFixtureEntry[]): Buffer {
}
let artifactCounter = 0;
function createTestToolSession(cwd: string, settings: Settings = Settings.isolated()): ToolSession {
function createTestToolSession(
cwd: string,
settings: Settings = Settings.isolated(),
overrides: Partial<ToolSession> = {},
): ToolSession {
const sessionFile = path.join(cwd, "session.jsonl");
const sessionDir = path.join(cwd, "session");
return {
@@ -186,6 +193,7 @@ function createTestToolSession(cwd: string, settings: Settings = Settings.isolat
return { id, path: path.join(sessionDir, `${id}.${toolType}.log`) };
},
settings,
...overrides,
};
}
@@ -969,6 +977,84 @@ function b() {
);
});
it("should keep short commands inline when auto-background is enabled", async () => {
const deliveries: string[] = [];
const asyncJobManager = new AsyncJobManager({
onJobComplete: async (_jobId, text) => {
deliveries.push(text);
},
});
const autoBackgroundBashTool = wrapToolWithMetaNotice(
new BashTool(
createTestToolSession(
testDir,
Settings.isolated({
"bash.autoBackground.enabled": true,
"bash.autoBackground.thresholdMs": 50,
}),
{
asyncJobManager,
getSessionId: () => "test-session",
},
),
),
);
const result = await autoBackgroundBashTool.execute("test-call-9-auto-inline", { command: "echo short" });
expect(getTextOutput(result)).toContain("short");
expect(result.details).toBeUndefined();
await Bun.sleep(150);
expect(deliveries).toEqual([]);
await asyncJobManager.dispose();
});
it("should auto-background long-running commands when enabled", 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": 50,
}),
{
asyncJobManager,
getSessionId: () => "test-session",
},
),
),
);
const result = await autoBackgroundBashTool.execute("test-call-9-auto-running", {
command: "printf 'start\\n'; sleep 0.2; printf 'done\\n'",
});
expect(result.details?.async?.state).toBe("running");
expect(result.details?.async?.type).toBe("bash");
expect(getTextOutput(result)).toContain("Background job");
expect(getTextOutput(result)).toContain("start");
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("done");
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,
@@ -995,6 +1081,16 @@ function b() {
/Working directory does not exist/,
);
});
it("should expose background-job tools when bash auto-background is enabled", () => {
const autoBackgroundSession = createTestToolSession(
testDir,
Settings.isolated({ "bash.autoBackground.enabled": true }),
);
expect(AwaitTool.createIf(autoBackgroundSession)).not.toBeNull();
expect(CancelJobTool.createIf(autoBackgroundSession)).not.toBeNull();
});
});
describe("grep tool", () => {