diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index bc6c7f22e..2cf378986 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/async/index.ts b/packages/coding-agent/src/async/index.ts index 7d036b77e..f04070903 100644 --- a/packages/coding-agent/src/async/index.ts +++ b/packages/coding-agent/src/async/index.ts @@ -1 +1,2 @@ export * from "./job-manager"; +export * from "./support"; diff --git a/packages/coding-agent/src/async/support.ts b/packages/coding-agent/src/async/support.ts new file mode 100644 index 000000000..10184393a --- /dev/null +++ b/packages/coding-agent/src/async/support.ts @@ -0,0 +1,5 @@ +import type { Settings } from "../config/settings"; + +export function isBackgroundJobSupportEnabled(settings: Pick): boolean { + return settings.get("async.enabled") || settings.get("bash.autoBackground.enabled"); +} diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index a92ec344a..25c973c05 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -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", diff --git a/packages/coding-agent/src/internal-urls/jobs-protocol.ts b/packages/coding-agent/src/internal-urls/jobs-protocol.ts index 5b492dd8b..c05136b0c 100644 --- a/packages/coding-agent/src/internal-urls/jobs-protocol.ts +++ b/packages/coding-agent/src/internal-urls/jobs-protocol.ts @@ -30,7 +30,8 @@ export class JobsProtocolHandler implements ProtocolHandler { async resolve(url: InternalUrl): Promise { 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, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 13803dbda..84b404741 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -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", diff --git a/packages/coding-agent/src/prompts/tools/bash.md b/packages/coding-agent/src/prompts/tools/bash.md index cf181aca6..54114deed 100644 --- a/packages/coding-agent/src/prompts/tools/bash.md +++ b/packages/coding-agent/src/prompts/tools/bash.md @@ -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://` 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}} diff --git a/packages/coding-agent/src/prompts/tools/cancel-job.md b/packages/coding-agent/src/prompts/tools/cancel-job.md index 7c03b6cd4..a24bd97ce 100644 --- a/packages/coding-agent/src/prompts/tools/cancel-job.md +++ b/packages/coding-agent/src/prompts/tools/cancel-job.md @@ -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. diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 07761211d..3a9c22896 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -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) => { diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index befdd581f..1311c013b 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -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, + }); } /** diff --git a/packages/coding-agent/src/tools/await-tool.ts b/packages/coding-agent/src/tools/await-tool.ts index d26841c9c..ccd80c061 100644 --- a/packages/coding-agent/src/tools/await-tool.ts +++ b/packages/coding-agent/src/tools/await-tool.ts @@ -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; + } + | { + kind: "failed"; + error: unknown; + }; + +interface ManagedBashJobHandle { + jobId: string; + label: string; + completion: Promise; + getLatestText: () => string; + setBackgrounded: (backgrounded: boolean) => void; +} + function normalizeResultOutput(result: BashResult | BashInteractiveResult): string { return result.output || ""; } @@ -212,12 +231,23 @@ export class BashTool implements AgentTool { 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 { return outputText; } + #buildCompletedResult( + result: BashResult | BashInteractiveResult, + timeoutSec: number, + headLines?: number, + tailLines?: number, + ): AgentToolResult { + 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 { + 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): 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; + onUpdate?: AgentToolUpdateCallback; + 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(); + + 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 { + if (signal?.aborted) { + return { kind: "aborted" }; + } + + const waiters: Array> = [ + 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 { 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 { 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); } } diff --git a/packages/coding-agent/src/tools/cancel-job.ts b/packages/coding-agent/src/tools/cancel-job.ts index 8a10a7ac7..224ec4c9a 100644 --- a/packages/coding-agent/src/tools/cancel-job.ts +++ b/packages/coding-agent/src/tools/cancel-job.ts @@ -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 = {}, +): 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", () => {