feat(subagent): add sessions_list tool for viewing spawned sub-tasks
Adds a new `sessions_list` tool to the Subagent tool group, allowing agents to query the status of their spawned sub-tasks. Supports both list mode (all runs) and detail mode (specific runId). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7726079648
commit
9cc89cf297
5 changed files with 363 additions and 1 deletions
|
|
@ -7,6 +7,7 @@ import { createProcessTool } from "./tools/process.js";
|
|||
import { createGlobTool } from "./tools/glob.js";
|
||||
import { createWebFetchTool, createWebSearchTool } from "./tools/web/index.js";
|
||||
import { createSessionsSpawnTool } from "./tools/sessions-spawn.js";
|
||||
import { createSessionsListTool } from "./tools/sessions-list.js";
|
||||
import { createMemorySearchTool } from "./tools/memory-search.js";
|
||||
import { createCronTool } from "./tools/cron/index.js";
|
||||
import { filterTools } from "./tools/policy.js";
|
||||
|
|
@ -133,6 +134,10 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
|
|||
});
|
||||
tools.push(sessionsSpawnTool as AgentTool<any>);
|
||||
|
||||
// Add sessions_list tool
|
||||
const sessionsListTool = createSessionsListTool({ sessionId });
|
||||
tools.push(sessionsListTool as AgentTool<any>);
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export const TOOL_GROUPS: Record<string, string[]> = {
|
|||
"group:memory": ["memory_search"],
|
||||
|
||||
// Subagent tools
|
||||
"group:subagent": ["sessions_spawn"],
|
||||
"group:subagent": ["sessions_spawn", "sessions_list"],
|
||||
|
||||
// Cron/scheduling tools
|
||||
"group:cron": ["cron"],
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export { createProcessTool } from "./process.js";
|
|||
export { createGlobTool } from "./glob.js";
|
||||
export { createWebFetchTool, createWebSearchTool } from "./web/index.js";
|
||||
export { createCronTool } from "./cron/index.js";
|
||||
export { createSessionsListTool } from "./sessions-list.js";
|
||||
|
||||
// Tool groups
|
||||
export {
|
||||
|
|
|
|||
169
src/agent/tools/sessions-list.test.ts
Normal file
169
src/agent/tools/sessions-list.test.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { SubagentRunRecord } from "../subagent/types.js";
|
||||
|
||||
// Mock the registry module before importing the tool
|
||||
vi.mock("../subagent/registry.js", () => ({
|
||||
listSubagentRuns: vi.fn(),
|
||||
getSubagentRun: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createSessionsListTool } from "./sessions-list.js";
|
||||
import { listSubagentRuns, getSubagentRun } from "../subagent/registry.js";
|
||||
|
||||
const mockListSubagentRuns = vi.mocked(listSubagentRuns);
|
||||
const mockGetSubagentRun = vi.mocked(getSubagentRun);
|
||||
|
||||
function makeRecord(overrides: Partial<SubagentRunRecord> = {}): SubagentRunRecord {
|
||||
return {
|
||||
runId: "run-001",
|
||||
childSessionId: "child-001",
|
||||
requesterSessionId: "parent-001",
|
||||
task: "Test task",
|
||||
cleanup: "delete",
|
||||
createdAt: 1700000000000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sessions_list tool", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty message when no runs exist", async () => {
|
||||
mockListSubagentRuns.mockReturnValue([]);
|
||||
const tool = createSessionsListTool({ sessionId: "parent-001" });
|
||||
const result = await tool.execute("call-1", {});
|
||||
|
||||
expect(result.content[0]).toEqual({
|
||||
type: "text",
|
||||
text: "No subagent runs for this session.",
|
||||
});
|
||||
expect(result.details).toEqual({ runs: [] });
|
||||
});
|
||||
|
||||
it("lists multiple runs with correct status mapping", async () => {
|
||||
const now = Date.now();
|
||||
const runs: SubagentRunRecord[] = [
|
||||
makeRecord({
|
||||
runId: "run-aaa",
|
||||
label: "Code Review",
|
||||
startedAt: now - 45000,
|
||||
}),
|
||||
makeRecord({
|
||||
runId: "run-bbb",
|
||||
label: "Test Analysis",
|
||||
startedAt: now - 60000,
|
||||
endedAt: now - 30000,
|
||||
outcome: { status: "ok" },
|
||||
}),
|
||||
makeRecord({
|
||||
runId: "run-ccc",
|
||||
label: "Lint Check",
|
||||
startedAt: now - 60000,
|
||||
endedAt: now,
|
||||
outcome: { status: "error", error: "timeout" },
|
||||
}),
|
||||
];
|
||||
mockListSubagentRuns.mockReturnValue(runs);
|
||||
|
||||
const tool = createSessionsListTool({ sessionId: "parent-001" });
|
||||
const result = await tool.execute("call-1", {});
|
||||
|
||||
const text = result.content[0]!;
|
||||
expect(text.type).toBe("text");
|
||||
expect((text as { text: string }).text).toContain("3 total");
|
||||
expect((text as { text: string }).text).toContain("[running]");
|
||||
expect((text as { text: string }).text).toContain("[ok]");
|
||||
expect((text as { text: string }).text).toContain("[error]");
|
||||
expect((text as { text: string }).text).toContain("Code Review");
|
||||
expect((text as { text: string }).text).toContain("Test Analysis");
|
||||
expect((text as { text: string }).text).toContain("Lint Check");
|
||||
|
||||
expect(result.details!.runs).toHaveLength(3);
|
||||
expect(result.details!.runs[0]!.status).toBe("running");
|
||||
expect(result.details!.runs[1]!.status).toBe("ok");
|
||||
expect(result.details!.runs[2]!.status).toBe("error");
|
||||
});
|
||||
|
||||
it("returns detail for a specific runId", async () => {
|
||||
const now = Date.now();
|
||||
const record = makeRecord({
|
||||
runId: "run-detail",
|
||||
label: "Deep Analysis",
|
||||
task: "Analyze the authentication module thoroughly",
|
||||
startedAt: now - 90000,
|
||||
endedAt: now - 10000,
|
||||
outcome: { status: "ok" },
|
||||
findings: "Found 2 potential issues in token validation.",
|
||||
findingsCaptured: true,
|
||||
});
|
||||
mockGetSubagentRun.mockReturnValue(record);
|
||||
|
||||
const tool = createSessionsListTool({ sessionId: "parent-001" });
|
||||
const result = await tool.execute("call-1", { runId: "run-detail" });
|
||||
|
||||
const text = (result.content[0] as { text: string }).text;
|
||||
expect(text).toContain("Run: run-detail");
|
||||
expect(text).toContain("Label: Deep Analysis");
|
||||
expect(text).toContain("Status: ok");
|
||||
expect(text).toContain("Found 2 potential issues");
|
||||
expect(text).toContain("Duration:");
|
||||
|
||||
expect(result.details!.runs).toHaveLength(1);
|
||||
expect(result.details!.runs[0]!.runId).toBe("run-detail");
|
||||
});
|
||||
|
||||
it("returns not found for unknown runId", async () => {
|
||||
mockGetSubagentRun.mockReturnValue(undefined);
|
||||
|
||||
const tool = createSessionsListTool({ sessionId: "parent-001" });
|
||||
const result = await tool.execute("call-1", { runId: "nonexistent" });
|
||||
|
||||
const text = (result.content[0] as { text: string }).text;
|
||||
expect(text).toContain("Run not found");
|
||||
expect(result.details).toEqual({ runs: [] });
|
||||
});
|
||||
|
||||
it("rejects runId belonging to a different requester", async () => {
|
||||
const record = makeRecord({
|
||||
runId: "run-other",
|
||||
requesterSessionId: "other-parent",
|
||||
});
|
||||
mockGetSubagentRun.mockReturnValue(record);
|
||||
|
||||
const tool = createSessionsListTool({ sessionId: "parent-001" });
|
||||
const result = await tool.execute("call-1", { runId: "run-other" });
|
||||
|
||||
const text = (result.content[0] as { text: string }).text;
|
||||
expect(text).toContain("Run not found");
|
||||
expect(result.details).toEqual({ runs: [] });
|
||||
});
|
||||
|
||||
it("handles missing sessionId gracefully", async () => {
|
||||
const tool = createSessionsListTool({});
|
||||
const result = await tool.execute("call-1", {});
|
||||
|
||||
const text = (result.content[0] as { text: string }).text;
|
||||
expect(text).toContain("No session ID available");
|
||||
expect(result.details).toEqual({ runs: [] });
|
||||
});
|
||||
|
||||
it("shows findings status for running task", async () => {
|
||||
const now = Date.now();
|
||||
const record = makeRecord({
|
||||
runId: "run-running",
|
||||
label: "Still Running",
|
||||
startedAt: now - 30000,
|
||||
// no endedAt
|
||||
});
|
||||
mockGetSubagentRun.mockReturnValue(record);
|
||||
|
||||
const tool = createSessionsListTool({ sessionId: "parent-001" });
|
||||
const result = await tool.execute("call-1", { runId: "run-running" });
|
||||
|
||||
const text = (result.content[0] as { text: string }).text;
|
||||
expect(text).toContain("Status: running");
|
||||
expect(text).toContain("Findings: (still running)");
|
||||
});
|
||||
});
|
||||
187
src/agent/tools/sessions-list.ts
Normal file
187
src/agent/tools/sessions-list.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* sessions_list tool — allows an agent to view its spawned subagent runs.
|
||||
*
|
||||
* Lists all subagent runs for the current session, or shows details for a
|
||||
* specific run when a runId is provided.
|
||||
*/
|
||||
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { listSubagentRuns, getSubagentRun } from "../subagent/registry.js";
|
||||
import type { SubagentRunRecord } from "../subagent/types.js";
|
||||
|
||||
const SessionsListSchema = Type.Object({
|
||||
runId: Type.Optional(
|
||||
Type.String({ description: "Optional run ID to get details for a specific run. If omitted, lists all runs." }),
|
||||
),
|
||||
});
|
||||
|
||||
type SessionsListArgs = {
|
||||
runId?: string;
|
||||
};
|
||||
|
||||
export type SessionsListResult = {
|
||||
runs: Array<{
|
||||
runId: string;
|
||||
label?: string;
|
||||
task: string;
|
||||
status: "running" | "ok" | "error" | "timeout" | "unknown";
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
findings?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export interface CreateSessionsListToolOptions {
|
||||
/** Session ID of the current (requester) agent */
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
function resolveStatus(record: SubagentRunRecord): "running" | "ok" | "error" | "timeout" | "unknown" {
|
||||
if (!record.endedAt) return "running";
|
||||
return record.outcome?.status ?? "unknown";
|
||||
}
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
const totalSeconds = Math.round(ms / 1000);
|
||||
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (minutes < 60) return seconds > 0 ? `${minutes}m${seconds}s` : `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return remainingMinutes > 0 ? `${hours}h${remainingMinutes}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
function formatRunSummary(record: SubagentRunRecord, index: number, now: number): string {
|
||||
const status = resolveStatus(record);
|
||||
const displayName = record.label || record.task.slice(0, 60);
|
||||
const statusTag = `[${status}]`.padEnd(10);
|
||||
|
||||
let timing = "";
|
||||
if (status === "running" && record.startedAt) {
|
||||
timing = `started ${formatElapsed(now - record.startedAt)} ago`;
|
||||
} else if (record.startedAt && record.endedAt) {
|
||||
timing = `completed in ${formatElapsed(record.endedAt - record.startedAt)}`;
|
||||
}
|
||||
|
||||
const parts = [`#${index + 1} ${statusTag} "${displayName}"`];
|
||||
if (timing) parts.push(`(${record.runId.slice(0, 8)}…, ${timing})`);
|
||||
else parts.push(`(${record.runId.slice(0, 8)}…)`);
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function formatRunDetail(record: SubagentRunRecord, now: number): string {
|
||||
const status = resolveStatus(record);
|
||||
const lines: string[] = [
|
||||
`Run: ${record.runId}`,
|
||||
];
|
||||
|
||||
if (record.label) lines.push(`Label: ${record.label}`);
|
||||
lines.push(`Task: ${record.task}`);
|
||||
lines.push(`Status: ${status}${record.outcome?.error ? ` — ${record.outcome.error}` : ""}`);
|
||||
lines.push(`Child Session: ${record.childSessionId}`);
|
||||
lines.push(`Created: ${new Date(record.createdAt).toISOString()} (${formatElapsed(now - record.createdAt)} ago)`);
|
||||
|
||||
if (record.startedAt) {
|
||||
lines.push(`Started: ${new Date(record.startedAt).toISOString()} (${formatElapsed(now - record.startedAt)} ago)`);
|
||||
}
|
||||
if (record.endedAt) {
|
||||
lines.push(`Ended: ${new Date(record.endedAt).toISOString()}`);
|
||||
if (record.startedAt) {
|
||||
lines.push(`Duration: ${formatElapsed(record.endedAt - record.startedAt)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (record.findingsCaptured) {
|
||||
lines.push(`Findings: ${record.findings || "(no output)"}`);
|
||||
} else if (record.endedAt) {
|
||||
lines.push("Findings: (not yet captured)");
|
||||
} else {
|
||||
lines.push("Findings: (still running)");
|
||||
}
|
||||
|
||||
if (record.announced) lines.push("Announced: yes");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function toResultRun(record: SubagentRunRecord) {
|
||||
return {
|
||||
runId: record.runId,
|
||||
label: record.label,
|
||||
task: record.task,
|
||||
status: resolveStatus(record),
|
||||
startedAt: record.startedAt,
|
||||
endedAt: record.endedAt,
|
||||
findings: record.findings,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSessionsListTool(
|
||||
options: CreateSessionsListToolOptions,
|
||||
): AgentTool<typeof SessionsListSchema, SessionsListResult> {
|
||||
return {
|
||||
name: "sessions_list",
|
||||
label: "List Subagent Runs",
|
||||
description:
|
||||
"List all subagent runs spawned by this session and their current status. " +
|
||||
"Optionally pass a runId to get detailed information about a specific run.",
|
||||
parameters: SessionsListSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const { runId } = args as SessionsListArgs;
|
||||
const requesterSessionId = options.sessionId;
|
||||
|
||||
if (!requesterSessionId) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No session ID available. Cannot list subagent runs." }],
|
||||
details: { runs: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Detail mode: specific run
|
||||
if (runId) {
|
||||
const record = getSubagentRun(runId);
|
||||
if (!record) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Run not found: ${runId}` }],
|
||||
details: { runs: [] },
|
||||
};
|
||||
}
|
||||
if (record.requesterSessionId !== requesterSessionId) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Run not found: ${runId}` }],
|
||||
details: { runs: [] },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: formatRunDetail(record, now) }],
|
||||
details: { runs: [toResultRun(record)] },
|
||||
};
|
||||
}
|
||||
|
||||
// List mode: all runs for this session
|
||||
const runs = listSubagentRuns(requesterSessionId);
|
||||
|
||||
if (runs.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No subagent runs for this session." }],
|
||||
details: { runs: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const lines = [`Subagent runs for this session: ${runs.length} total`, ""];
|
||||
for (let i = 0; i < runs.length; i++) {
|
||||
lines.push(formatRunSummary(runs[i]!, i, now));
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: { runs: runs.map(toResultRun) },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue