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:
@@ -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 +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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user