feat: background_agent tool, detached launcher, status counts, extension integration, prompts/docs
This commit is contained in:
80
index.ts
80
index.ts
@@ -67,6 +67,46 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
monitorRun,
|
monitorRun,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// background registry and helpers
|
||||||
|
const registry = (typeof createBackgroundRegistry === 'function') ? createBackgroundRegistry() : undefined;
|
||||||
|
let latestUi: { notify(message: string, type?: "info" | "warning" | "error"): void; setStatus(key: string, text: string | undefined): void } | undefined;
|
||||||
|
|
||||||
|
function renderCounts() {
|
||||||
|
if (!registry) return undefined;
|
||||||
|
const counts = registry.getCounts();
|
||||||
|
return counts.total === 0 ? undefined : `bg: ${counts.running} running / ${counts.total} total`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus() {
|
||||||
|
latestUi?.setStatus("pi-subagents", renderCounts());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function watchBackgroundRun(runId: string) {
|
||||||
|
if (!registry) return;
|
||||||
|
const run = registry.getRun(runId);
|
||||||
|
if (!run || !run.paths || !run.paths.eventsPath || !run.paths.resultPath) return;
|
||||||
|
try {
|
||||||
|
const result = await monitorRun({ eventsPath: run.paths.eventsPath, resultPath: run.paths.resultPath });
|
||||||
|
const status = result.stopReason === "aborted" ? "aborted" : result.exitCode === 0 ? "completed" : "failed";
|
||||||
|
registry.recordUpdate(runId, { status, exitCode: result.exitCode, finalText: result.finalText, stopReason: result.stopReason });
|
||||||
|
try {
|
||||||
|
pi.appendEntry("pi-subagents:bg-update", { runId, status, finalText: result.finalText, exitCode: result.exitCode });
|
||||||
|
} catch (e) {}
|
||||||
|
updateStatus();
|
||||||
|
latestUi?.notify(`Background agent ${run.preset} finished: ${result.finalText || status}`, status === "completed" ? "info" : "error");
|
||||||
|
try {
|
||||||
|
pi.sendMessage({
|
||||||
|
customType: "pi-subagents:bg-complete",
|
||||||
|
content: `Background agent ${run.preset} completed (${runId}): ${result.finalText || status}`,
|
||||||
|
display: true,
|
||||||
|
details: { runId, status },
|
||||||
|
}, { triggerTurn: false });
|
||||||
|
} catch (e) {}
|
||||||
|
} catch (e) {
|
||||||
|
// monitorRun errors are non-fatal here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const runSingleTask = createConfiguredRunSingleTask({
|
const runSingleTask = createConfiguredRunSingleTask({
|
||||||
loadConfig: (cwd) => loadSubagentsConfig(cwd),
|
loadConfig: (cwd) => loadSubagentsConfig(cwd),
|
||||||
processRunner,
|
processRunner,
|
||||||
@@ -98,6 +138,24 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
runSingleTask,
|
runSingleTask,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// also register background tools when models available
|
||||||
|
try {
|
||||||
|
pi.registerTool(createBackgroundStatusTool({ registry }));
|
||||||
|
pi.registerTool(createBackgroundAgentTool({
|
||||||
|
parameters: createBackgroundAgentSchema(availableModels),
|
||||||
|
discoverSubagentPresets,
|
||||||
|
launchDetachedTask: processRunner.launchDetachedTask,
|
||||||
|
registerBackgroundRun(entry: any) {
|
||||||
|
if (!registry) return { running: 0, completed: 0, failed: 0, aborted: 0, total: 0 };
|
||||||
|
registry.recordLaunch({ runId: entry.runId, preset: entry.preset, task: entry.task, requestedModel: entry.requestedModel, resolvedModel: entry.resolvedModel, paths: entry.paths, meta: entry.meta });
|
||||||
|
return registry.getCounts();
|
||||||
|
},
|
||||||
|
watchBackgroundRun(runId: string) {
|
||||||
|
void watchBackgroundRun(runId);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (e) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncSubagentTool = (ctx: { modelRegistry: { getAvailable(): Array<{ provider: string; id: string }> } }) => {
|
const syncSubagentTool = (ctx: { modelRegistry: { getAvailable(): Array<{ provider: string; id: string }> } }) => {
|
||||||
@@ -105,10 +163,32 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
pi.on("session_start", (_event, ctx) => {
|
pi.on("session_start", (_event, ctx) => {
|
||||||
|
latestUi = ctx.ui;
|
||||||
|
// replay persisted runs if session manager provides entries
|
||||||
|
try {
|
||||||
|
const entries = ctx.sessionManager?.getEntries?.() ?? [];
|
||||||
|
const bgEntries = entries.filter((e: any) => e.type === "pi-subagents:bg-run" || e.type === "pi-subagents:bg-update");
|
||||||
|
// convert to registry runs if possible
|
||||||
|
const runs = bgEntries.map((be: any) => be.data?.run).filter(Boolean);
|
||||||
|
if (registry && runs.length) registry.replay(runs);
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
updateStatus();
|
||||||
|
|
||||||
|
// reattach watchers for running runs
|
||||||
|
try {
|
||||||
|
if (registry) {
|
||||||
|
for (const r of registry.getSnapshot({ includeCompleted: true })) {
|
||||||
|
if (r.status === "running") void watchBackgroundRun(r.runId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
syncSubagentTool(ctx);
|
syncSubagentTool(ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
pi.on("before_agent_start", (_event, ctx) => {
|
pi.on("before_agent_start", (_event, ctx) => {
|
||||||
|
latestUi = ctx.ui;
|
||||||
syncSubagentTool(ctx);
|
syncSubagentTool(ctx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ export function createBackgroundStatusTool(deps: { registry: BackgroundRegistry
|
|||||||
// when querying a single run by id, allow completed runs regardless of includeCompleted flag
|
// 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 runs = runId ? registry.getSnapshot({ runId, includeCompleted: true }) : registry.getSnapshot({ includeCompleted });
|
||||||
|
|
||||||
|
const counts = registry.getCounts();
|
||||||
|
|
||||||
if (runId) {
|
if (runId) {
|
||||||
if (runs.length === 0) {
|
if (runs.length === 0) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text" as const, text: `No run found for runId: ${runId}` }],
|
content: [{ type: "text" as const, text: `No run found for runId: ${runId}` }],
|
||||||
details: { runs: [] },
|
details: { runs: [], counts },
|
||||||
isError: false,
|
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)"}"`;
|
const text = `Run ${r.runId}: status=${r.status}, task=${r.task}, final="${r.finalText ?? "(no output)"}"`;
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text" as const, text }],
|
content: [{ type: "text" as const, text }],
|
||||||
details: { runs },
|
details: { runs, counts },
|
||||||
isError: false,
|
isError: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// default: active runs only (unless includeCompleted)
|
// default: active runs only (unless includeCompleted)
|
||||||
const active = runs;
|
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 {
|
return {
|
||||||
content: [{ type: "text" as const, text }],
|
content: [{ type: "text" as const, text }],
|
||||||
details: { runs: active },
|
details: { runs: active, counts },
|
||||||
isError: false,
|
isError: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
61
src/background-tool.test.ts
Normal file
61
src/background-tool.test.ts
Normal 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
65
src/background-tool.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -35,10 +35,13 @@ export function createProcessSingleRunner(deps: {
|
|||||||
buildWrapperSpawn: (metaPath: string) => { command: string; args: string[]; env?: NodeJS.ProcessEnv };
|
buildWrapperSpawn: (metaPath: string) => { command: string; args: string[]; env?: NodeJS.ProcessEnv };
|
||||||
spawnChild?: typeof spawn;
|
spawnChild?: typeof spawn;
|
||||||
monitorRun: (input: { eventsPath: string; resultPath: string; onEvent?: (event: any) => void }) => Promise<any>;
|
monitorRun: (input: { eventsPath: string; resultPath: string; onEvent?: (event: any) => void }) => Promise<any>;
|
||||||
|
// new detached launcher helper
|
||||||
|
createDetachedLauncher?: (deps: any) => any;
|
||||||
}): RunSingleTask {
|
}): RunSingleTask {
|
||||||
const spawnChild = deps.spawnChild ?? spawn;
|
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 artifacts = await deps.createArtifacts(input.cwd, input.meta);
|
||||||
const spawnSpec = deps.buildWrapperSpawn(artifacts.metaPath);
|
const spawnSpec = deps.buildWrapperSpawn(artifacts.metaPath);
|
||||||
|
|
||||||
@@ -79,4 +82,62 @@ export function createProcessSingleRunner(deps: {
|
|||||||
eventsPath: artifacts.eventsPath,
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user