feat: background_agent tool, detached launcher, status counts, extension integration, prompts/docs
This commit is contained in:
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
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 };
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user