feat: background_agent tool, detached launcher, status counts, extension integration, prompts/docs

This commit is contained in:
pi
2026-04-12 14:01:38 +01:00
parent 0c18d021df
commit 6c87a9a1a2
5 changed files with 274 additions and 5 deletions

View File

@@ -17,11 +17,13 @@ export function createBackgroundStatusTool(deps: { registry: BackgroundRegistry
// when querying a single run by id, allow completed runs regardless of includeCompleted flag
const runs = runId ? registry.getSnapshot({ runId, includeCompleted: true }) : registry.getSnapshot({ includeCompleted });
const counts = registry.getCounts();
if (runId) {
if (runs.length === 0) {
return {
content: [{ type: "text" as const, text: `No run found for runId: ${runId}` }],
details: { runs: [] },
details: { runs: [], counts },
isError: false,
};
}
@@ -29,17 +31,17 @@ export function createBackgroundStatusTool(deps: { registry: BackgroundRegistry
const text = `Run ${r.runId}: status=${r.status}, task=${r.task}, final="${r.finalText ?? "(no output)"}"`;
return {
content: [{ type: "text" as const, text }],
details: { runs },
details: { runs, counts },
isError: false,
};
}
// default: active runs only (unless includeCompleted)
const active = runs;
const text = `Active runs: ${active.length}`;
const text = `Active runs: ${active.length}. Counts: running=${counts.running} completed=${counts.completed} failed=${counts.failed} aborted=${counts.aborted} total=${counts.total}`;
return {
content: [{ type: "text" as const, text }],
details: { runs: active },
details: { runs: active, counts },
isError: false,
};
},

View File

@@ -0,0 +1,61 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createBackgroundAgentTool } from "./background-tool.ts";
test("background_agent returns immediately with handle metadata and counts", async () => {
const appended: Array<{ type: string; data: any }> = [];
let watchCalled = false;
const tool = createBackgroundAgentTool({
discoverSubagentPresets: () => ({
presets: [
{
name: "repo-scout",
description: "Scout",
model: "openai/gpt-5",
systemPrompt: "Scout",
source: "global",
filePath: "/tmp/repo-scout.md",
},
],
projectPresetsDir: null,
}),
launchDetachedTask: async () => ({
runId: "run-1",
task: "inspect auth",
requestedModel: "openai/gpt-5",
resolvedModel: "openai/gpt-5",
resultPath: "/repo/.pi/subagents/runs/run-1/result.json",
eventsPath: "/repo/.pi/subagents/runs/run-1/events.jsonl",
stdoutPath: "/repo/.pi/subagents/runs/run-1/stdout.log",
stderrPath: "/repo/.pi/subagents/runs/run-1/stderr.log",
transcriptPath: "/repo/.pi/subagents/runs/run-1/transcript.log",
sessionPath: "/repo/.pi/subagents/runs/run-1/child-session.jsonl",
}),
registerBackgroundRun(run: any) {
appended.push({ type: "run", data: run });
return { running: 1, completed: 0, failed: 0, aborted: 0, total: 1 };
},
watchBackgroundRun() {
watchCalled = true;
},
} as any);
const result: any = await tool.execute(
"tool-1",
{ preset: "repo-scout", task: "inspect auth" },
undefined,
undefined,
{
cwd: "/repo",
modelRegistry: { getAvailable: () => [{ provider: "openai", id: "gpt-5" }] },
hasUI: false,
} as any,
);
assert.equal(result.isError, false);
assert.equal(result.details.runId, "run-1");
assert.deepEqual(result.details.counts, { running: 1, completed: 0, failed: 0, aborted: 0, total: 1 });
assert.equal(watchCalled, true);
assert.equal(appended.length, 1);
});

65
src/background-tool.ts Normal file
View File

@@ -0,0 +1,65 @@
import { Text } from "@mariozechner/pi-tui";
import { discoverSubagentPresets } from "./presets.ts";
import { normalizeAvailableModelReference, listAvailableModelReferences } from "./models.ts";
import { BackgroundAgentSchema } from "./background-schema.ts";
export function createBackgroundAgentTool(deps: any = {}) {
return {
name: "background_agent",
label: "Background Agent",
description: "Spawn a detached background child session and return its run handle immediately.",
parameters: deps.parameters ?? BackgroundAgentSchema,
async execute(_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: any, ctx: any) {
const discovery = (deps.discoverSubagentPresets ?? discoverSubagentPresets)(ctx.cwd);
const presets = discovery.presets;
if (typeof params.preset !== "string" || params.preset.trim() === "") {
return { content: [{ type: "text" as const, text: "Missing preset" }], isError: true };
}
const preset = presets.find((p: any) => p.name === params.preset);
if (!preset) {
const names = presets.map((p: any) => p.name).join(", ") || "(none)";
return { content: [{ type: "text" as const, text: `Unknown preset "${params.preset}". Available presets: ${names}` }], isError: true };
}
const available = (deps.listAvailableModelReferences ?? listAvailableModelReferences)(ctx.modelRegistry);
const availableText = available.join(", ") || "(none)";
const normalizedModel = (deps.normalizeAvailableModelReference ?? normalizeAvailableModelReference)(params.model ?? preset.model, available);
if (!normalizedModel) {
return { content: [{ type: "text" as const, text: `Background agent requires a model chosen from the available models: ${availableText}` }], isError: true };
}
const launch = await deps.launchDetachedTask({
cwd: params.cwd ?? ctx.cwd,
meta: {
mode: "background",
preset: preset.name,
presetSource: preset.source,
task: params.task,
cwd: params.cwd ?? ctx.cwd,
requestedModel: normalizedModel,
resolvedModel: normalizedModel,
systemPrompt: preset.systemPrompt,
tools: preset.tools,
},
});
const counts = deps.registerBackgroundRun({ runId: launch.runId, preset: preset.name, task: params.task, requestedModel: normalizedModel, resolvedModel: normalizedModel, paths: { resultPath: launch.resultPath, eventsPath: launch.eventsPath } });
deps.watchBackgroundRun(launch.runId);
return {
content: [{ type: "text" as const, text: `Background agent started: ${launch.runId}` }],
details: { runId: launch.runId, counts },
isError: false,
};
},
renderCall() {
return new Text("background_agent", 0, 0);
},
renderResult(result: { content: Array<{ type: string; text?: string }> }) {
const first = result.content[0];
return new Text(first?.type === "text" ? first.text ?? "" : "", 0, 0);
},
};
}

View File

@@ -35,10 +35,13 @@ export function createProcessSingleRunner(deps: {
buildWrapperSpawn: (metaPath: string) => { command: string; args: string[]; env?: NodeJS.ProcessEnv };
spawnChild?: typeof spawn;
monitorRun: (input: { eventsPath: string; resultPath: string; onEvent?: (event: any) => void }) => Promise<any>;
// new detached launcher helper
createDetachedLauncher?: (deps: any) => any;
}): RunSingleTask {
const spawnChild = deps.spawnChild ?? spawn;
return async function runSingleTask(input) {
// keep existing behavior
const runSingleTaskImpl = async function runSingleTask(input: any) {
const artifacts = await deps.createArtifacts(input.cwd, input.meta);
const spawnSpec = deps.buildWrapperSpawn(artifacts.metaPath);
@@ -79,4 +82,62 @@ export function createProcessSingleRunner(deps: {
eventsPath: artifacts.eventsPath,
};
};
// create detached launcher that returns immediately
function createDetachedLauncher() {
return async function launchDetachedTask(input: { cwd: string; meta: Record<string, unknown>; spawnChild?: typeof spawn }) {
const artifacts = await deps.createArtifacts(input.cwd, input.meta);
const spawnSpec = deps.buildWrapperSpawn(artifacts.metaPath);
const useSpawn = input.spawnChild ?? spawnChild;
try {
const child = useSpawn(spawnSpec.command, spawnSpec.args, {
cwd: input.cwd,
env: { ...process.env, ...(spawnSpec.env ?? {}) },
stdio: ["ignore", "ignore", "ignore"],
});
child.once("error", (error) => {
void (async () => {
const r = makeLaunchFailureResult(artifacts, input.meta, input.cwd, error);
await writeFile(artifacts.resultPath, JSON.stringify(r, null, 2), "utf8");
})();
});
} catch (error) {
const r = makeLaunchFailureResult(artifacts, input.meta, input.cwd, error);
await writeFile(artifacts.resultPath, JSON.stringify(r, null, 2), "utf8");
return {
runId: artifacts.runId,
sessionPath: artifacts.sessionPath,
stdoutPath: artifacts.stdoutPath,
stderrPath: artifacts.stderrPath,
transcriptPath: artifacts.transcriptPath,
resultPath: artifacts.resultPath,
eventsPath: artifacts.eventsPath,
...input.meta,
exitCode: 1,
stopReason: "error",
};
}
return {
runId: artifacts.runId,
sessionPath: artifacts.sessionPath,
stdoutPath: artifacts.stdoutPath,
stderrPath: artifacts.stderrPath,
transcriptPath: artifacts.transcriptPath,
resultPath: artifacts.resultPath,
eventsPath: artifacts.eventsPath,
...input.meta,
};
};
}
const detached = createDetachedLauncher();
// expose both runSingleTask and detached launcher
const fn: any = runSingleTaskImpl as any;
(fn as any).launchDetachedTask = detached;
return fn as RunSingleTask;
}