feat(pi): improve subagent monitoring #41

Merged
alexion merged 6 commits from subagent-labels into main 2026-08-01 23:27:48 -04:00
9 changed files with 157 additions and 28 deletions
Showing only changes of commit c5828e0591 - Show all commits

View File

@@ -115,9 +115,10 @@ test("named spawn resolves overrides, frontmatter, config, and defaults", () =>
const config = loadConfig(cwd, true, diag, agentDir); const config = loadConfig(cwd, true, diag, agentDir);
const agents = loadAgents(cwd, true, diag, agentDir); const agents = loadAgents(cwd, true, diag, agentDir);
const resolved = resolveSpawn({ agent: "review", prompt: "check this", thinking: "low" }, config, agents); const resolved = resolveSpawn({ agent: "review", prompt: "check this", label: "Review migration", thinking: "low" }, config, agents);
assert.equal(resolved.prompt, "check this"); assert.equal(resolved.prompt, "check this");
assert.equal(resolved.label, "Review migration");
assert.equal(resolved.context, "independent"); assert.equal(resolved.context, "independent");
assert.equal(resolved.model, "inherit"); assert.equal(resolved.model, "inherit");
assert.equal(resolved.thinking, "low"); assert.equal(resolved.thinking, "low");

View File

@@ -4,6 +4,7 @@ import { loadAgents } from "./agents.ts";
import { loadConfig, resolveSpawn, type Diagnostics } from "./config.ts"; import { loadConfig, resolveSpawn, type Diagnostics } from "./config.ts";
import { SubprocessRpcRunner } from "./runner.ts"; import { SubprocessRpcRunner } from "./runner.ts";
import { Supervisor } from "./supervisor.ts"; import { Supervisor } from "./supervisor.ts";
import { milestoneNotification } from "./status.ts";
import type { SpawnRequest, SubagentStatus } from "./types.ts"; import type { SpawnRequest, SubagentStatus } from "./types.ts";
import { widget } from "./ui.ts"; import { widget } from "./ui.ts";
@@ -23,7 +24,11 @@ export default function subagents(pi: ExtensionAPI) {
supervisor = new Supervisor(new SubprocessRpcRunner(), cwd, { supervisor = new Supervisor(new SubprocessRpcRunner(), cwd, {
maxConcurrent: config.maxConcurrent, maxConcurrent: config.maxConcurrent,
recentTerminalTtlMs: config.recentTerminalTtlMs, recentTerminalTtlMs: config.recentTerminalTtlMs,
onMilestone: (status, event) => pi.appendEntry("subagent_milestone", { event, status }), onMilestone: (status, event) => {
pi.appendEntry("subagent_milestone", { event, status });
const notification = milestoneNotification(status, event);
if (notification) ctx.ui?.notify?.(notification.message, notification.level);
},
onChange: (statuses) => { onChange: (statuses) => {
lastStatuses = statuses; lastStatuses = statuses;
updateUi(ctx, config.ui.enabled); updateUi(ctx, config.ui.enabled);
@@ -51,6 +56,7 @@ export default function subagents(pi: ExtensionAPI) {
description: "Start one ad hoc independent subagent and return immediately with its child id", description: "Start one ad hoc independent subagent and return immediately with its child id",
parameters: Type.Object({ parameters: Type.Object({
prompt: Type.String({ description: "Prompt for the delegated subagent" }), prompt: Type.String({ description: "Prompt for the delegated subagent" }),
label: Type.Optional(Type.String({ description: "Human-readable label for this work item" })),
agent: Type.Optional(Type.String({ description: "Named agent definition to use" })), agent: Type.Optional(Type.String({ description: "Named agent definition to use" })),
context: Type.Optional(Type.Union([Type.Literal("independent"), Type.Literal("fork")])), context: Type.Optional(Type.Union([Type.Literal("independent"), Type.Literal("fork")])),
model: Type.Optional(Type.String({ description: "Optional model selector for the child" })), model: Type.Optional(Type.String({ description: "Optional model selector for the child" })),
@@ -59,7 +65,7 @@ export default function subagents(pi: ExtensionAPI) {
}), }),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const accepted = getSupervisor(ctx).spawn(resolve(ctx, params as SpawnRequest)); const accepted = getSupervisor(ctx).spawn(resolve(ctx, params as SpawnRequest));
ctx.ui?.notify?.(`Started subagent ${accepted.id}`, "info"); ctx.ui?.notify?.(`Started subagent ${accepted.label}`, "info");
return textResult(accepted); return textResult(accepted);
}, },
}); });
@@ -72,6 +78,7 @@ export default function subagents(pi: ExtensionAPI) {
subagents: Type.Array( subagents: Type.Array(
Type.Object({ Type.Object({
prompt: Type.String({ description: "Prompt for the delegated subagent" }), prompt: Type.String({ description: "Prompt for the delegated subagent" }),
label: Type.Optional(Type.String({ description: "Human-readable label for this work item" })),
agent: Type.Optional(Type.String({ description: "Named agent definition to use" })), agent: Type.Optional(Type.String({ description: "Named agent definition to use" })),
context: Type.Optional(Type.Union([Type.Literal("independent"), Type.Literal("fork")])), context: Type.Optional(Type.Union([Type.Literal("independent"), Type.Literal("fork")])),
model: Type.Optional(Type.String({ description: "Optional model selector for the child" })), model: Type.Optional(Type.String({ description: "Optional model selector for the child" })),
@@ -164,7 +171,7 @@ export default function subagents(pi: ExtensionAPI) {
description: "Start an ad hoc independent subagent", description: "Start an ad hoc independent subagent",
handler: async (args, ctx) => { handler: async (args, ctx) => {
const accepted = getSupervisor(ctx).spawn(resolve(ctx, parseSpawnArgs(args))); const accepted = getSupervisor(ctx).spawn(resolve(ctx, parseSpawnArgs(args)));
ctx.ui.notify(`Started subagent ${accepted.id}`, "info"); ctx.ui.notify(`Started subagent ${accepted.label}`, "info");
}, },
}); });
@@ -250,6 +257,7 @@ function parseSpawnArgs(args: string): SpawnRequest {
const flag = parts.shift(); const flag = parts.shift();
const value = parts.shift(); const value = parts.shift();
if (flag === "--agent") request.agent = value; if (flag === "--agent") request.agent = value;
else if (flag === "--label") request.label = value;
else if (flag === "--context" && (value === "independent" || value === "fork")) request.context = value; else if (flag === "--context" && (value === "independent" || value === "fork")) request.context = value;
else if (flag === "--tools") request.tools = value; else if (flag === "--tools") request.tools = value;
else if (flag === "--model") request.model = value; else if (flag === "--model") request.model = value;

View File

@@ -51,14 +51,18 @@ test("child RPC process disables discovery while explicitly loading subagents ex
const { SubprocessRpcRunner } = await import("./runner.ts"); const { SubprocessRpcRunner } = await import("./runner.ts");
const runner = new SubprocessRpcRunner(); const runner = new SubprocessRpcRunner();
await runner.start("child-1", { prompt: "work" }, "/tmp", events()); await runner.start("child-1", { prompt: "work", label: "Review migration" }, "/tmp", events());
assert.equal(spawn.mock.callCount(), 1); assert.equal(spawn.mock.callCount(), 1);
const args = calls[0].args; const args = calls[0].args;
const noExtensionsIndex = args.indexOf("--no-extensions"); const noExtensionsIndex = args.indexOf("--no-extensions");
const extensionIndex = args.indexOf("--extension"); const extensionIndex = args.indexOf("--extension");
const nameIndex = args.indexOf("--name");
assert.notEqual(noExtensionsIndex, -1, "child args keep automatic extension discovery disabled"); assert.notEqual(noExtensionsIndex, -1, "child args keep automatic extension discovery disabled");
assert.notEqual(nameIndex, -1, "child args include a process name");
assert.equal(args[nameIndex + 1], "subagent Review migration");
assert.notEqual(extensionIndex, -1, "child args explicitly load the subagents extension entry"); assert.notEqual(extensionIndex, -1, "child args explicitly load the subagents extension entry");
assert.equal(args[extensionIndex + 1], fileURLToPath(new URL("./index.ts", import.meta.url))); assert.equal(args[extensionIndex + 1], fileURLToPath(new URL("./index.ts", import.meta.url)));
assert.ok(noExtensionsIndex < extensionIndex); assert.ok(noExtensionsIndex < extensionIndex);

View File

@@ -157,7 +157,7 @@ class RpcChildHandle implements ChildHandle {
export class SubprocessRpcRunner implements ChildRunner { export class SubprocessRpcRunner implements ChildRunner {
async start(id: string, request: SpawnRequest, cwd: string, events: RunnerEvents): Promise<ChildHandle> { async start(id: string, request: SpawnRequest, cwd: string, events: RunnerEvents): Promise<ChildHandle> {
const args = [process.argv[1], "--mode", "rpc", "--no-extensions", "--extension", subagentsExtensionPath(), "--name", `subagent ${id}`, ...contextArgs(request), ...toolArgs(request), ...modelArgs(request)]; const args = [process.argv[1], "--mode", "rpc", "--no-extensions", "--extension", subagentsExtensionPath(), "--name", `subagent ${request.label ?? id}`, ...contextArgs(request), ...toolArgs(request), ...modelArgs(request)];
const child = spawn(process.execPath, args, { const child = spawn(process.execPath, args, {
cwd, cwd,
env: childEnvironment(), env: childEnvironment(),

View File

@@ -1,4 +1,5 @@
import type { ChildRecord, SpawnAccepted, SubagentResult, SubagentStatus } from "./types.ts"; import { SUBAGENT_STATES, SUBAGENT_TERMINAL_STATES } from "./types.ts";
import type { ChildRecord, SpawnAccepted, SubagentResult, SubagentState, SubagentStatus } from "./types.ts";
export function toAccepted(status: SubagentStatus): SpawnAccepted { export function toAccepted(status: SubagentStatus): SpawnAccepted {
return { return {
@@ -17,9 +18,10 @@ export function cloneStatus(status: SubagentStatus): SubagentStatus {
export function cloneResult(record: ChildRecord): SubagentResult { export function cloneResult(record: ChildRecord): SubagentResult {
const status = cloneStatus(record.status); const status = cloneStatus(record.status);
const terminal = ["completed", "failed", "cancelled", "timed_out", "orphaned"].includes(status.state); const terminal = isTerminalState(status.state);
return { return {
id: status.id, id: status.id,
label: status.label,
state: status.state, state: status.state,
running: !terminal, running: !terminal,
resultAvailable: status.resultAvailable, resultAvailable: status.resultAvailable,
@@ -30,6 +32,19 @@ export function cloneResult(record: ChildRecord): SubagentResult {
}; };
} }
export function isTerminalState(state: SubagentState): boolean {
return (SUBAGENT_TERMINAL_STATES as readonly string[]).includes(state);
}
export function milestoneNotification(status: SubagentStatus, event: string): { message: string; level: "info" | "error" } | undefined {
if (!isSubagentState(event) || !isTerminalState(event)) return undefined;
return { message: `Subagent ${status.label} ${event}`, level: event === "completed" ? "info" : "error" };
}
export function isSubagentState(value: string): value is SubagentState {
return (SUBAGENT_STATES as readonly string[]).includes(value);
}
export function elapsedMs(status: Pick<SubagentStatus, "startedAt" | "completedAt">): number { export function elapsedMs(status: Pick<SubagentStatus, "startedAt" | "completedAt">): number {
const start = Date.parse(status.startedAt); const start = Date.parse(status.startedAt);
const end = status.completedAt ? Date.parse(status.completedAt) : Date.now(); const end = status.completedAt ? Date.parse(status.completedAt) : Date.now();

View File

@@ -1,7 +1,9 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { milestoneNotification } from "./status.ts";
import { Supervisor } from "./supervisor.ts"; import { Supervisor } from "./supervisor.ts";
import type { ChildHandle, ChildRunner, RunnerEvents, SpawnRequest } from "./types.ts"; import type { ChildHandle, ChildRunner, RunnerEvents, SpawnRequest } from "./types.ts";
import { widget } from "./ui.ts";
class FakeHandle implements ChildHandle { class FakeHandle implements ChildHandle {
cancelCalls = 0; cancelCalls = 0;
@@ -110,6 +112,77 @@ test("completed children ignore later cancel", async () => {
assert.equal(runner.starts[0].handle.cancelCalls, 0); assert.equal(runner.starts[0].handle.cancelCalls, 0);
}); });
test("explicit labels are reused across accepted status list and result surfaces", async () => {
const runner = new FakeRunner();
const supervisor = new Supervisor(runner, "/tmp");
const label = "Review risky migration";
const accepted = supervisor.spawn({ prompt: "inspect the migration plan", label } as SpawnRequest & { label: string });
await sleep(0);
runner.starts[0].events.completed("done", "agent_settled");
assert.deepEqual(
{
accepted: accepted.label,
status: supervisor.status(accepted.id).label,
list: supervisor.list().find((status) => status.id === accepted.id)?.label,
result: (supervisor.result(accepted.id) as { label?: string }).label,
},
{
accepted: label,
status: label,
list: label,
result: label,
},
);
});
test("ad hoc fallback labels are prompt-derived and reused by widget and result surfaces", async () => {
const runner = new FakeRunner();
const supervisor = new Supervisor(runner, "/tmp");
const prompt = " Audit\n\tguest enablement plan ";
const label = "Audit guest enablement plan";
const accepted = supervisor.spawn({ prompt });
await sleep(0);
runner.starts[0].events.completed("done", "agent_settled");
const statuses = supervisor.list();
const inspectorLines = widget(statuses, true)().render(240);
assert.deepEqual(
{
accepted: accepted.label,
childRequest: runner.starts[0].request.label,
status: supervisor.status(accepted.id).label,
list: statuses.find((status) => status.id === accepted.id)?.label,
result: supervisor.result(accepted.id).label,
},
{
accepted: label,
childRequest: label,
status: label,
list: label,
result: label,
},
);
assert.ok(inspectorLines.some((line) => line.includes(`${accepted.id} ${label} independent completed`)), inspectorLines.join("\n"));
assert.doesNotMatch(accepted.label, /^ad-hoc sg-/u);
});
test("milestone notifications use the stored label", async () => {
const runner = new FakeRunner();
const supervisor = new Supervisor(runner, "/tmp");
const accepted = supervisor.spawn({ prompt: "work", label: "Review migration" });
await sleep(0);
runner.starts[0].events.completed("done", "agent_settled");
assert.deepEqual(milestoneNotification(supervisor.status(accepted.id), "completed"), {
message: "Subagent Review migration completed",
level: "info",
});
assert.equal(milestoneNotification(supervisor.status(accepted.id), "running"), undefined);
});
test("shutdown clears recent terminal expiry timer", async () => { test("shutdown clears recent terminal expiry timer", async () => {
const runner = new FakeRunner(); const runner = new FakeRunner();
let changes = 0; let changes = 0;
@@ -128,17 +201,22 @@ test("shutdown clears recent terminal expiry timer", async () => {
assert.equal(changes, afterShutdown); assert.equal(changes, afterShutdown);
}); });
test("batch spawn returns accepted ids and per-entry failures", async () => { test("batch spawn returns explicit labels on accepted child requests and statuses while preserving failures", async () => {
const runner = new FakeRunner(); const runner = new FakeRunner();
const supervisor = new Supervisor(runner, "/tmp"); const supervisor = new Supervisor(runner, "/tmp");
const result = supervisor.spawnBatch([{ prompt: "one" }, { prompt: "" }, { prompt: "two" }]); const result = supervisor.spawnBatch([
{ prompt: "one", label: "Review docs" },
{ prompt: "" },
{ prompt: "two", label: "Check tests" },
]);
await sleep(0); await sleep(0);
assert.equal(result.accepted.length, 2); assert.deepEqual(result.accepted.map((accepted) => accepted.label), ["Review docs", "Check tests"]);
assert.equal(result.failed.length, 1); assert.equal(result.failed.length, 1);
assert.equal(result.failed[0].index, 1); assert.equal(result.failed[0].index, 1);
assert.equal(runner.starts.length, 2); assert.deepEqual(runner.starts.map((start) => start.request.label), ["Review docs", "Check tests"]);
assert.deepEqual(result.accepted.map((accepted) => supervisor.status(accepted.id).label), ["Review docs", "Check tests"]);
}); });
test("maxConcurrent preserves queued records", async () => { test("maxConcurrent preserves queued records", async () => {

View File

@@ -11,7 +11,7 @@ import type {
SubagentWaitMode, SubagentWaitMode,
SubagentWaitResult, SubagentWaitResult,
} from "./types.ts"; } from "./types.ts";
import { cloneResult, cloneStatus, toAccepted } from "./status.ts"; import { cloneResult, cloneStatus, isTerminalState, toAccepted } from "./status.ts";
interface RunningChild { interface RunningChild {
record: ChildRecord; record: ChildRecord;
@@ -157,7 +157,7 @@ export class Supervisor {
const now = new Date().toISOString(); const now = new Date().toISOString();
const status: SubagentStatus = { const status: SubagentStatus = {
id, id,
label: request.agent ?? `ad-hoc ${id}`, label: deriveLabel(request, id),
agent: request.agent, agent: request.agent,
adHoc: !request.agent, adHoc: !request.agent,
context: this.resolveContext(request.context), context: this.resolveContext(request.context),
@@ -172,7 +172,7 @@ export class Supervisor {
lastEventAt: now, lastEventAt: now,
resultAvailable: false, resultAvailable: false,
}; };
const child: RunningChild = { record: { status }, request: { ...request, prompt, context: status.context, tools: status.tools } }; const child: RunningChild = { record: { status }, request: { ...request, prompt, label: status.label, context: status.context, tools: status.tools } };
this.children.set(id, child); this.children.set(id, child);
this.emitMilestone(child, "accepted"); this.emitMilestone(child, "accepted");
this.queue.push(child); this.queue.push(child);
@@ -418,6 +418,32 @@ export class Supervisor {
} }
} }
function isTerminal(state: SubagentStatus["state"]): boolean { function deriveLabel(request: SpawnRequest, id: string): string {
return ["completed", "failed", "cancelled", "timed_out", "orphaned"].includes(state); const explicit = normalizeLabel(request.label);
if (explicit) return explicit;
const agent = normalizeLabel(request.agent);
if (agent) return agent;
return promptLabel(request.prompt) ?? `ad-hoc ${id}`;
}
function promptLabel(prompt: string): string | undefined {
const normalized = normalizeLabel(prompt);
if (!normalized) return undefined;
return truncateLabel(normalized);
}
function normalizeLabel(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.replace(/\s+/gu, " ").trim();
return normalized || undefined;
}
function truncateLabel(label: string): string {
const maxLength = 80;
if (label.length <= maxLength) return label;
return `${label.slice(0, maxLength - 1).trimEnd()}`;
}
function isTerminal(state: SubagentStatus["state"]): boolean {
return isTerminalState(state);
} }

View File

@@ -1,15 +1,9 @@
export type ContextMode = "independent" | "fork"; export type ContextMode = "independent" | "fork";
export type SubagentState = export const SUBAGENT_STATES = ["queued", "starting", "running", "settling", "completed", "failed", "cancelled", "timed_out", "orphaned"] as const;
| "queued" export const SUBAGENT_TERMINAL_STATES = ["completed", "failed", "cancelled", "timed_out", "orphaned"] as const;
| "starting"
| "running" export type SubagentState = (typeof SUBAGENT_STATES)[number];
| "settling"
| "completed"
| "failed"
| "cancelled"
| "timed_out"
| "orphaned";
export interface ToolProfile { export interface ToolProfile {
activeTools: string[] | null; activeTools: string[] | null;
@@ -17,6 +11,7 @@ export interface ToolProfile {
export interface SpawnRequest { export interface SpawnRequest {
prompt: string; prompt: string;
label?: string;
context?: ContextMode; context?: ContextMode;
agent?: string; agent?: string;
model?: string; model?: string;
@@ -60,6 +55,7 @@ export interface SubagentStatus {
export interface SubagentResult { export interface SubagentResult {
id: string; id: string;
label: string;
state: SubagentState; state: SubagentState;
running: boolean; running: boolean;
resultAvailable: boolean; resultAvailable: boolean;

View File

@@ -1,9 +1,10 @@
import { isTerminalState } from "./status.ts";
import type { SubagentStatus } from "./types.ts"; import type { SubagentStatus } from "./types.ts";
export function renderSummary(statuses: SubagentStatus[]): string[] { export function renderSummary(statuses: SubagentStatus[]): string[] {
const running = statuses.filter((status) => ["starting", "running", "settling"].includes(status.state)).length; const running = statuses.filter((status) => ["starting", "running", "settling"].includes(status.state)).length;
const queued = statuses.filter((status) => status.state === "queued").length; const queued = statuses.filter((status) => status.state === "queued").length;
const terminal = statuses.filter((status) => ["completed", "failed", "cancelled", "timed_out", "orphaned"].includes(status.state)).length; const terminal = statuses.filter((status) => isTerminalState(status.state)).length;
if (running === 0 && queued === 0 && terminal === 0) return []; if (running === 0 && queued === 0 && terminal === 0) return [];
return [`subagents: ${running} running · ${queued} queued · ${terminal} recent`]; return [`subagents: ${running} running · ${queued} queued · ${terminal} recent`];
} }