Merge pull request #55 from jmfederico/feat/subsession-yield

feat: add explicit tracked subsession yielding
This commit is contained in:
Federico Jaramillo Martinez
2026-07-14 00:09:51 +02:00
committed by GitHub
9 changed files with 455 additions and 66 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add explicit tracked-subsession yielding with no-poll wake-up guidance, remaining-child status, and clear boundaries around child output.
+25 -14
View File
@@ -525,24 +525,35 @@
<h3><code>subsessions</code></h3> <h3><code>subsessions</code></h3>
<p> <p>
Boolean. Beta. Controls whether agents receive the tracked-subsession tools: Boolean. Beta. Controls whether agents receive the tracked-subsession tools:
<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, and <code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>,
<code>read_subsession</code>. Defaults to <code>false</code> and also requires <code>spawnSessions</code> <code>read_subsession</code>, and <code>yield_to_subsessions</code>. Defaults to <code>false</code> and also
to be enabled. requires <code>spawnSessions</code> to be enabled.
</p> </p>
<p> <p>
Tracked subsessions let an agent delegate work to child sessions, receive a notification when each child Tracked subsessions are join-oriented. Calling <code>spawn_subsession</code> returns immediately, so the
stops working, and inspect their status and transcripts. Calling <code>spawn_subsession</code> returns parent can continue independent work while the child runs. Work whose result the parent does not need to
immediately. The parent can continue independent work while treating every child whose result it needs join belongs in the fire-and-forget <code>spawn_session</code> tool instead.
as pending. Before producing work that depends on those results, the parent reaches a join point and
yields until every required child has sent a completion notice.
</p> </p>
<p> <p>
A completion notice wakes an idle parent. If the parent is busy, the notice queues until the current At a join point, after finishing its independent work, the parent calls
turn ends rather than interrupting in-flight work. For multiple required children, each notice resolves <code>yield_to_subsessions</code> alone as the final action in its tool batch. Pi ends a tool batch early
one pending child; after processing it, the parent yields again if another required child is pending. only when every result in that batch is terminating. If any tracked child is still working, the action
<code>list_subsessions</code>, <code>check_subsession</code>, and <code>read_subsession</code> provide ends the current agent run so the parent becomes idle. If none are working, it does not end the run and
on-demand status and transcript inspection for deliberate progress checks or recovery. Completion clearly reports that there is nothing to wait for.
notifications, rather than polling these tools, are the normal synchronization mechanism. </p>
<p>
A completion notice wakes an idle parent or queues behind in-flight work. Each notice lists any other
tracked children still working, so the parent can continue work or call
<code>yield_to_subsessions</code> again at the next join point. Further notices arrive automatically; do
not poll.
</p>
<p>
<code>list_subsessions</code>, <code>check_subsession</code>, and <code>read_subsession</code> never yield
or change control flow. They are for deliberate inspection or recovery, not completion polling. While a
child works, agent-facing <code>check_subsession</code> and <code>read_subsession</code> withhold partial
output and direct the parent to continue independent work or yield at the join point. Output becomes
available when the child stops. In notices and inspection results, PI WEB guidance precedes a labeled
marker and the child output or transcript always comes last.
</p> </p>
<p> <p>
In <strong>Settings → Session daemon</strong>, these keys are saved on the selected machine. Restart the In <strong>Settings → Session daemon</strong>, these keys are saved on the selected machine. Restart the
+7 -3
View File
@@ -181,11 +181,15 @@ The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX
`spawnSessions` controls whether agents receive the `spawn_session` tool. It defaults to `true`; set it to `false` if you do not want an agent to start independent PI WEB sessions. `spawnSessions` controls whether agents receive the `spawn_session` tool. It defaults to `true`; set it to `false` if you do not want an agent to start independent PI WEB sessions.
`subsessions` is beta and controls whether agents receive the tracked-subsession tools: `spawn_subsession`, `list_subsessions`, `check_subsession`, and `read_subsession`. It defaults to `false` and also requires `spawnSessions` to be enabled. `subsessions` is beta and controls whether agents receive the tracked-subsession tools: `spawn_subsession`, `list_subsessions`, `check_subsession`, `read_subsession`, and `yield_to_subsessions`. It defaults to `false` and also requires `spawnSessions` to be enabled.
Tracked subsessions let an agent delegate work to child sessions, receive a notification when each child stops working, and inspect their status and transcripts. Calling `spawn_subsession` returns immediately. The parent can continue independent work while treating every child whose result it needs as pending. Before producing work that depends on those results, the parent reaches a join point and yields until every required child has sent a completion notice. Tracked subsessions are join-oriented. Calling `spawn_subsession` returns immediately, so the parent can continue independent work while the child runs. Work whose result the parent does not need to join belongs in the fire-and-forget `spawn_session` tool instead.
A completion notice wakes an idle parent. If the parent is busy, the notice queues until the current turn ends rather than interrupting in-flight work. For multiple required children, each notice resolves one pending child; after processing it, the parent yields again if another required child is pending. `list_subsessions`, `check_subsession`, and `read_subsession` provide on-demand status and transcript inspection for deliberate progress checks or recovery. Completion notifications, rather than polling these tools, are the normal synchronization mechanism. At a join point, after finishing its independent work, the parent calls `yield_to_subsessions` alone as the final action in its tool batch. Pi ends a tool batch early only when every result in that batch is terminating. If any tracked child is still working, the action ends the current agent run so the parent becomes idle. If none are working, it does not end the run and clearly reports that there is nothing to wait for.
A completion notice wakes an idle parent or queues behind in-flight work. Each notice lists any other tracked children still working, so the parent can continue work or call `yield_to_subsessions` again at the next join point. Further notices arrive automatically; do not poll.
`list_subsessions`, `check_subsession`, and `read_subsession` never yield or change control flow. They are for deliberate inspection or recovery, not completion polling. While a child works, agent-facing `check_subsession` and `read_subsession` withhold partial output and direct the parent to continue independent work or yield at the join point. Output becomes available when the child stops. In notices and inspection results, PI WEB guidance precedes a labeled marker and the child output or transcript always comes last.
In **Settings → Session daemon**, these keys are saved on the selected machine. Restart the session daemon on that machine after changing them. In **Settings → Session daemon**, these keys are saved on the selected machine. Restart the session daemon on that machine after changing them.
@@ -46,6 +46,7 @@ describe("delegation tool capability boundary", () => {
"list_subsessions", "list_subsessions",
"check_subsession", "check_subsession",
"read_subsession", "read_subsession",
"yield_to_subsessions",
]); ]);
}); });
@@ -8,10 +8,15 @@ import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionMa
describe("PiSessionService", () => { describe("PiSessionService", () => {
describe("spawnSubsession", () => { describe("spawnSubsession", () => {
function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000) { function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000, childIds = ["child-1"]) {
const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" }); const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" });
const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") }); const children = childIds.map((childId) => fakeRuntime(childId, {
const created = [parent.runtime, child.runtime]; sessionFile: `/tmp/${childId}.jsonl`,
sessionManager: fakeSessionManager("/workspace-feature"),
}));
const child = children[0];
if (child === undefined) throw new Error("At least one child fixture is required");
const created = [parent.runtime, ...children.map(({ runtime }) => runtime)];
let index = 0; let index = 0;
const createAgentRuntime: RuntimeCreator = async () => { const createAgentRuntime: RuntimeCreator = async () => {
await Promise.resolve(); await Promise.resolve();
@@ -38,7 +43,7 @@ describe("PiSessionService", () => {
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) }, spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
heartbeatIntervalMs, heartbeatIntervalMs,
}); });
return { parent, child, service }; return { parent, child, children, service };
} }
it("records the parent, delivers the prompt, and lists the tracked child", async () => { it("records the parent, delivers the prompt, and lists the tracked child", async () => {
@@ -777,6 +782,41 @@ describe("PiSessionService", () => {
await service.dispose(); await service.dispose();
}); });
it("reports other working children in each completion notice", async () => {
const { parent, children, service } = subsessionService(
{ allowed: true, cwd: "/workspace-feature" },
60_000,
["child-1", "child-2"],
);
const [first, second] = children;
if (first === undefined || second === undefined) throw new Error("Expected two child fixtures");
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "first", cwd: "/workspace-feature" });
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "second", cwd: "/workspace-feature" });
first.session.isStreaming = true;
first.emit({ type: "agent_start" });
second.session.isStreaming = true;
second.emit({ type: "agent_start" });
first.session.isStreaming = false;
first.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage[0]?.message.content).toBe(
"Subsession child-1 stopped working (idle).\nStill working: child-2. Continue working, or call yield_to_subsessions alone and last at the next join point. Further completion notices arrive automatically; do not poll.\n\n--- SUBSESSION OUTPUT: child-1 ---\n(no output)",
);
second.session.isStreaming = false;
second.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage[1]?.message.content).toBe(
"Subsession child-2 stopped working (idle).\nNo other tracked subsessions are working.\n\n--- SUBSESSION OUTPUT: child-2 ---\n(no output)",
);
await service.dispose();
});
it("notifies via the heartbeat when the child settles without a further event", async () => { it("notifies via the heartbeat when the child settles without a further event", async () => {
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }, 10); const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }, 10);
await service.start("/workspace"); await service.start("/workspace");
+15 -1
View File
@@ -898,6 +898,16 @@ export class PiSessionService {
return "idle"; return "idle";
} }
private workingSubsessionIds(parentSessionId: string): string[] {
const childIds = this.subsessionChildren.get(parentSessionId);
if (childIds === undefined) return [];
return [...childIds].filter((childId) => {
const link = this.subsessionLinks.get(childId);
const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link);
return active !== undefined && this.hasActiveWork(active.runtime.session);
});
}
/** /**
* Drive parent notifications from a tracked child's status. Arms a pending * Drive parent notifications from a tracked child's status. Arms a pending
* notification while the child is working, and when it stops fires a single * notification while the child is working, and when it stops fires a single
@@ -917,7 +927,11 @@ export class PiSessionService {
const status: SubsessionStatus = this.activities.get(childId)?.phase === "error" ? "error" : "idle"; const status: SubsessionStatus = this.activities.get(childId)?.phase === "error" ? "error" : "idle";
const finalText = finalAssistantText(historyMessages(session)); const finalText = finalAssistantText(historyMessages(session));
const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText); const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText);
const text = `Subsession ${childId} stopped working (status: ${status}). Latest output:\n\n${preview}\n\nStatus and latest output are available through check_subsession with sessionId "${childId}"; its full transcript is available through read_subsession.`; const workingIds = this.workingSubsessionIds(link.parentSessionId);
const next = workingIds.length === 0
? "No other tracked subsessions are working."
: `Still working: ${workingIds.join(", ")}. Continue working, or call yield_to_subsessions alone and last at the next join point. Further completion notices arrive automatically; do not poll.`;
const text = `Subsession ${childId} stopped working (${status}).\n${next}\n\n--- SUBSESSION OUTPUT: ${childId} ---\n${preview}`;
void this.notifyParentOfSubsession(link.parentSessionId, childId, text); void this.notifyParentOfSubsession(link.parentSessionId, childId, text);
} }
@@ -0,0 +1,163 @@
import type { Api, AssistantMessage, Message, Model } from "@earendil-works/pi-ai";
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
import { runAgentLoop, type AgentEvent, type AgentMessage, type AgentTool, type StreamFn } from "@earendil-works/pi-agent-core";
import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { describe, expect, it, vi } from "vitest";
import { createSubsessionToolDefinitions, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
const model: Model<Api> = {
id: "fake-model",
name: "Fake Model",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://example.test",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_000,
maxTokens: 100,
};
function extensionContext(): ExtensionContext {
const sessionManager = {
getSessionId: () => "parent-1",
getSessionFile: () => "/sessions/parent-1.jsonl",
};
// The wrapped yield definition only reads the two session-manager methods above.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- minimal integration boundary for a Pi tool definition.
return { sessionManager } as unknown as ExtensionContext;
}
function wrapDefinition(definition: ToolDefinition, ctx: ExtensionContext): AgentTool {
return {
name: definition.name,
label: definition.label,
description: definition.description,
parameters: definition.parameters,
...(definition.executionMode === undefined ? {} : { executionMode: definition.executionMode }),
execute: (toolCallId, params, signal, onUpdate) => definition.execute(toolCallId, params, signal, onUpdate, ctx),
};
}
function isLlmMessage(agentMessage: AgentMessage): agentMessage is Message {
return agentMessage.role === "user" || agentMessage.role === "assistant" || agentMessage.role === "toolResult";
}
function message(stopReason: "stop" | "toolUse", content: AssistantMessage["content"]): AssistantMessage {
return {
role: "assistant",
content,
api: "anthropic-messages",
provider: "anthropic",
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason,
timestamp: 0,
};
}
function streamSequence(messages: AssistantMessage[]): StreamFn {
let index = 0;
return vi.fn(() => {
const next = messages[index];
index += 1;
if (next === undefined) throw new Error("unexpected provider invocation");
if (next.stopReason !== "stop" && next.stopReason !== "toolUse" && next.stopReason !== "length") {
throw new Error(`unsupported fake stop reason ${next.stopReason}`);
}
const stream = createAssistantMessageEventStream();
stream.push({ type: "done", reason: next.stopReason, message: next });
stream.end(next);
return stream;
});
}
async function runYieldBatch(subsessions: SubsessionSummary[], includeSentinel = false) {
const list = vi.fn(() => Promise.resolve(subsessions));
const deps: SubsessionToolDeps = {
spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace" })),
list,
check: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace", status: "idle" as const, finalText: "", messageCount: 0 })),
read: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace", status: "idle" as const, entries: [], total: 0, matched: 0, start: 0, hasMore: false })),
};
const yieldDefinition = createSubsessionToolDefinitions("/workspace", deps)
.find(({ name }) => name === "yield_to_subsessions");
if (yieldDefinition === undefined) throw new Error("missing yield_to_subsessions");
const sentinel = vi.fn(() => Promise.resolve({ content: [{ type: "text" as const, text: "sentinel complete" }], details: {} }));
const sentinelTool: AgentTool = {
name: "sentinel",
label: "Sentinel",
description: "Return normally.",
parameters: Type.Object({}),
execute: sentinel,
};
const firstContent: AssistantMessage["content"] = [
{ type: "toolCall", id: "yield-call", name: "yield_to_subsessions", arguments: {} },
...(includeSentinel
? [{ type: "toolCall" as const, id: "sentinel-call", name: "sentinel", arguments: {} }]
: []),
];
const streamFn = streamSequence([
message("toolUse", firstContent),
message("stop", [{ type: "text", text: "normal follow-up" }]),
]);
const events: AgentEvent[] = [];
const messages = await runAgentLoop(
[{ role: "user", content: "join now", timestamp: 0 }],
{
systemPrompt: "",
messages: [],
tools: [wrapDefinition(yieldDefinition, extensionContext()), sentinelTool],
},
{ model, convertToLlm: (agentMessages) => agentMessages.filter(isLlmMessage) },
(event) => { events.push(event); },
undefined,
streamFn,
);
return { events, list, messages, sentinel, streamFn };
}
describe("yield_to_subsessions Pi agent-loop integration", () => {
it("ends the run after one provider call when invoked alone with a working child", async () => {
const result = await runYieldBatch([
{ sessionId: "child-1", cwd: "/workspace", status: "working" },
]);
expect(result.streamFn).toHaveBeenCalledTimes(1);
expect(result.list).toHaveBeenCalledWith("parent-1", "/sessions/parent-1.jsonl");
expect(result.events.slice(-2).map(({ type }) => type)).toEqual(["turn_end", "agent_end"]);
expect(result.messages.at(-1)).toMatchObject({ role: "toolResult", toolName: "yield_to_subsessions" });
});
it("makes a normal follow-up provider call when no child is working", async () => {
const result = await runYieldBatch([]);
expect(result.streamFn).toHaveBeenCalledTimes(2);
expect(result.events.filter(({ type }) => type === "turn_start")).toHaveLength(2);
expect(result.messages.at(-1)).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "normal follow-up" }],
});
});
it("does not terminate a mixed batch with a non-terminating sibling tool", async () => {
const result = await runYieldBatch([
{ sessionId: "child-1", cwd: "/workspace", status: "working" },
], true);
expect(result.sentinel).toHaveBeenCalledTimes(1);
expect(result.streamFn).toHaveBeenCalledTimes(2);
expect(result.messages.at(-1)).toMatchObject({ role: "assistant" });
});
});
+127 -18
View File
@@ -25,7 +25,17 @@ function tools(deps: Partial<SubsessionToolDeps>) {
if (tool === undefined) throw new Error(`missing tool ${name}`); if (tool === undefined) throw new Error(`missing tool ${name}`);
return tool; return tool;
}; };
return { spawn: find("spawn_subsession"), list: find("list_subsessions"), check: find("check_subsession"), read: find("read_subsession") }; return {
spawn: find("spawn_subsession"),
list: find("list_subsessions"),
check: find("check_subsession"),
read: find("read_subsession"),
yield: find("yield_to_subsessions"),
};
}
function workingGuidance(sessionId: string): string {
return `Subsession ${sessionId} is working; partial output is withheld. Continue independent work, or call yield_to_subsessions alone and last at the join point. Completion notices wake you; do not poll.`;
} }
function firstText(content: readonly (TextContent | ImageContent)[]): string { function firstText(content: readonly (TextContent | ImageContent)[]): string {
@@ -52,27 +62,40 @@ describe("createSubsessionToolDefinitions", () => {
expect(firstText(result.content)).toContain("Started tracked subsession child-1"); expect(firstText(result.content)).toContain("Started tracked subsession child-1");
}); });
it("guides the parent to join all required subsessions without polling", async () => { it("guides the parent to continue independent work and use the explicit join action", async () => {
const { spawn: spawnTool } = tools({ const { spawn: spawnTool } = tools({
spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" })), spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" })),
}); });
expect(spawnTool.description).toBe("Start a tracked child and return after dispatch. Track required children as pending: continue independent work, then yield at a join point until all have notified completion. Notifications queue while the parent is busy; do not poll for completion."); expect(spawnTool.description).toBe("Start a tracked child and return immediately. Continue independent work, then use yield_to_subsessions at the join point. Completion notices wake you; do not poll.");
expect(spawnTool.promptSnippet).toBe("spawn_subsession: delegate parallel work; yield at a join point until all required children complete."); expect(spawnTool.promptSnippet).toBe("spawn_subsession: tracked parallel work; continue, then join with yield_to_subsessions");
const result = await spawnTool.execute("call-contract", { prompt: "do it" }, undefined, undefined, ctxFor("parent-1", undefined)); const result = await spawnTool.execute("call-contract", { prompt: "do it" }, undefined, undefined, ctxFor("parent-1", undefined));
expect(firstText(result.content)).toBe("Started tracked subsession child-1 in /repos/a-feature. Track it as pending and, before finalizing dependent work, yield until all required children have notified completion."); expect(firstText(result.content)).toBe("Started tracked subsession child-1 in /repos/a-feature. Continue independent work, then join with yield_to_subsessions; do not poll.");
}); });
it("keeps subsession inspection tool descriptions capability-oriented", () => { it("distinguishes status inspection from yielding in tool metadata", () => {
const definitions = tools({}); const definitions = tools({});
expect(definitions.list.description).toBe("List tracked child sessions owned by the calling session, with each child's current status (working, idle, error, or unknown)."); expect(definitions.list.description).toBe("List tracked child statuses. Never yields or changes control flow; do not poll.");
expect(definitions.check.description).toBe("Return a tracked subsession's current status, message count, and most recent assistant output."); expect(definitions.list.promptSnippet).toBe("list_subsessions: inspect child statuses; never yields");
expect(definitions.read.description).toBe("Return a filtered, paginated transcript of a tracked subsession. Filters select message roles and content kinds, search full message content, optionally include raw tool arguments, and cap or page the returned entries."); expect(definitions.check.description).toBe("Get a tracked child's status and latest output. Working output is withheld. Never yields; do not poll.");
for (const definition of [definitions.list, definitions.check, definitions.read]) { expect(definitions.check.promptSnippet).toBe("check_subsession: inspect child status and available output; never yields");
expect(definition.description).not.toMatch(/use this|do not poll|continue working|start narrow|for just the final|relay/i); expect(definitions.read.description).toBe("Read a tracked child's filtered transcript. Working transcripts are withheld. Never yields; do not poll.");
} expect(definitions.read.promptSnippet).toBe("read_subsession: inspect an available child transcript; never yields");
});
it("registers the parameterless yield action with terminal-batch guidance", () => {
const { yield: yieldTool } = tools({});
expect(yieldTool.parameters).toMatchObject({ type: "object", properties: {} });
expect(yieldTool.description).toBe("At a join point, end this run while tracked children work; completion notices wake you. If none work, continue. Call alone and last; do not poll.");
expect(yieldTool.promptSnippet).toBe("yield_to_subsessions: end the run at a join point; call alone and last");
expect(yieldTool.promptGuidelines).toEqual([
"After independent work, yield only at a join point; use spawn_session for fire-and-forget work.",
"Call alone and last; a mixed tool batch may continue the run.",
"Completion notices wake you; do not poll inspection tools.",
]);
}); });
it("spawn_subsession omits the inherited model when the dispatching session has no current model", async () => { it("spawn_subsession omits the inherited model when the dispatching session has no current model", async () => {
@@ -105,12 +128,51 @@ describe("createSubsessionToolDefinitions", () => {
{ sessionId: "child-2", cwd: "/repos/a", status: "idle" }, { sessionId: "child-2", cwd: "/repos/a", status: "idle" },
] }); ] });
expect(firstText(result.content)).toContain("child-1 [working]"); expect(firstText(result.content)).toContain("child-1 [working]");
expect(result.terminate).toBeUndefined();
}); });
it("list_subsessions reports an empty state", async () => { it("list_subsessions reports an empty state", async () => {
const { list: listTool } = tools({ list: vi.fn(() => Promise.resolve([])) }); const { list: listTool } = tools({ list: vi.fn(() => Promise.resolve([])) });
const result = await listTool.execute("call-3", {}, undefined, undefined, ctxFor("parent-1", undefined)); const result = await listTool.execute("call-3", {}, undefined, undefined, ctxFor("parent-1", undefined));
expect(result.content[0]).toMatchObject({ type: "text", text: "No tracked subsessions." }); expect(result.content[0]).toMatchObject({ type: "text", text: "No tracked subsessions." });
expect(result.terminate).toBeUndefined();
});
it("yield_to_subsessions terminates when tracked children are working", async () => {
const subsessions = [
{ sessionId: "child-1", cwd: "/repos/a", status: "working" as const },
{ sessionId: "child-2", cwd: "/repos/a", status: "idle" as const },
{ sessionId: "child-3", cwd: "/repos/a", status: "working" as const },
];
const list = vi.fn(() => Promise.resolve(subsessions));
const { yield: yieldTool } = tools({ list });
const result = await yieldTool.execute("call-yield", {}, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
expect(list).toHaveBeenCalledWith("parent-1", "/sessions/parent-1.jsonl");
expect(result.details).toEqual({ subsessions });
expect(firstText(result.content)).toBe("Working: child-1, child-3. Ending this run; completion notices will wake you.");
expect(result.terminate).toBe(true);
});
it.each([
{ label: "an empty list", subsessions: [] },
{
label: "only non-working children",
subsessions: [
{ sessionId: "child-idle", cwd: "/repos/a", status: "idle" as const },
{ sessionId: "child-error", cwd: "/repos/a", status: "error" as const },
{ sessionId: "child-unknown", cwd: "/repos/a", status: "unknown" as const },
],
},
])("yield_to_subsessions remains active with $label", async ({ subsessions }) => {
const { yield: yieldTool } = tools({ list: vi.fn(() => Promise.resolve(subsessions)) });
const result = await yieldTool.execute("call-no-yield", {}, undefined, undefined, ctxFor("parent-1", undefined));
expect(result.details).toEqual({ subsessions });
expect(firstText(result.content)).toBe("No tracked subsessions are working; continuing.");
expect(result.terminate).toBeUndefined();
}); });
it("check_subsession scopes by parent and returns the final result", async () => { it("check_subsession scopes by parent and returns the final result", async () => {
@@ -121,7 +183,33 @@ describe("createSubsessionToolDefinitions", () => {
expect(check).toHaveBeenCalledWith("parent-1", "child-1", "/sessions/parent-1.jsonl"); expect(check).toHaveBeenCalledWith("parent-1", "child-1", "/sessions/parent-1.jsonl");
expect(result.details).toMatchObject({ sessionId: "child-1", status: "idle", finalText: "all done" }); expect(result.details).toMatchObject({ sessionId: "child-1", status: "idle", finalText: "all done" });
expect(firstText(result.content)).toContain("all done"); expect(firstText(result.content)).toBe("Subsession child-1 [idle].\n\n--- SUBSESSION OUTPUT: child-1 ---\nall done");
expect(result.terminate).toBeUndefined();
});
it("check_subsession withholds partial working output without yielding", async () => {
const partial = { sessionId: "child-1", cwd: "/repos/a", status: "working" as const, finalText: "SECRET PARTIAL OUTPUT", messageCount: 4 };
const check = vi.fn(() => Promise.resolve(partial));
const { check: checkTool } = tools({ check });
const result = await checkTool.execute("call-working-check", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
expect(check).toHaveBeenCalledWith("parent-1", "child-1", "/sessions/parent-1.jsonl");
expect(firstText(result.content)).toBe(workingGuidance("child-1"));
expect(firstText(result.content)).not.toContain("SECRET PARTIAL OUTPUT");
expect(result.details).toEqual(partial);
expect(result.terminate).toBeUndefined();
});
it("check_subsession preserves non-working error output without yielding", async () => {
const { check: checkTool } = tools({
check: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a", status: "error" as const, finalText: "child failed", messageCount: 3 })),
});
const result = await checkTool.execute("call-error-check", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", undefined));
expect(firstText(result.content)).toBe("Subsession child-1 [error].\n\n--- SUBSESSION OUTPUT: child-1 ---\nchild failed");
expect(result.terminate).toBeUndefined();
}); });
it("check_subsession propagates scope errors so the agent loop reports them", async () => { it("check_subsession propagates scope errors so the agent loop reports them", async () => {
@@ -136,15 +224,34 @@ describe("createSubsessionToolDefinitions", () => {
const read = vi.fn(() => Promise.resolve({ const read = vi.fn(() => Promise.resolve({
sessionId: "child-1", cwd: "/repos/a", status: "idle" as const, sessionId: "child-1", cwd: "/repos/a", status: "idle" as const,
entries: [{ index: 2, role: "assistant" as const, parts: [{ kind: "text" as const, text: "the answer" }] }], entries: [{ index: 2, role: "assistant" as const, parts: [{ kind: "text" as const, text: "the answer" }] }],
total: 5, matched: 1, start: 2, hasMore: false, total: 5, matched: 2, start: 2, hasMore: true,
})); }));
const { read: readTool } = tools({ read }); const { read: readTool } = tools({ read });
const result = await readTool.execute("call-6", { sessionId: "child-1", roles: ["assistant"], maxChars: 200 }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl")); const result = await readTool.execute("call-6", { sessionId: "child-1", roles: ["assistant"], maxChars: 200, limit: 1 }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
expect(read).toHaveBeenCalledWith("parent-1", "child-1", { roles: ["assistant"], maxChars: 200 }, "/sessions/parent-1.jsonl"); expect(read).toHaveBeenCalledWith("parent-1", "child-1", { roles: ["assistant"], maxChars: 200, limit: 1 }, "/sessions/parent-1.jsonl");
expect(result.details).toMatchObject({ sessionId: "child-1", matched: 1 }); expect(result.details).toMatchObject({ sessionId: "child-1", matched: 2 });
expect(firstText(result.content)).toContain("the answer"); expect(firstText(result.content)).toBe("Subsession child-1 [idle] — messages 22 of 5 (2 matched). Earlier matching messages exist before index 2.\n\n--- SUBSESSION TRANSCRIPT: child-1 ---\n#2 assistant\nthe answer");
expect(result.terminate).toBeUndefined();
});
it("read_subsession withholds partial working transcripts without yielding", async () => {
const partial = {
sessionId: "child-1", cwd: "/repos/a", status: "working" as const,
entries: [{ index: 2, role: "assistant" as const, parts: [{ kind: "text" as const, text: "SECRET TRANSCRIPT ENTRY" }] }],
total: 3, matched: 1, start: 2, hasMore: false,
};
const read = vi.fn(() => Promise.resolve(partial));
const { read: readTool } = tools({ read });
const result = await readTool.execute("call-working-read", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
expect(read).toHaveBeenCalledWith("parent-1", "child-1", {}, "/sessions/parent-1.jsonl");
expect(firstText(result.content)).toBe(workingGuidance("child-1"));
expect(firstText(result.content)).not.toContain("SECRET TRANSCRIPT ENTRY");
expect(result.details).toEqual(partial);
expect(result.terminate).toBeUndefined();
}); });
it("read_subsession renders raw tool-call args and the truncation marker in the model-facing text", async () => { it("read_subsession renders raw tool-call args and the truncation marker in the model-facing text", async () => {
@@ -165,6 +272,7 @@ describe("createSubsessionToolDefinitions", () => {
expect(text).toContain("command"); // raw args surfaced in text, not only details expect(text).toContain("command"); // raw args surfaced in text, not only details
expect(text).toContain("ls -la"); expect(text).toContain("ls -la");
expect(text).toContain("[+43 chars truncated"); // 50 - 7 expect(text).toContain("[+43 chars truncated"); // 50 - 7
expect(result.terminate).toBeUndefined();
}); });
it("read_subsession distinguishes an empty page-window from a zero-match result", async () => { it("read_subsession distinguishes an empty page-window from a zero-match result", async () => {
@@ -178,6 +286,7 @@ describe("createSubsessionToolDefinitions", () => {
const text = firstText(result.content); const text = firstText(result.content);
expect(text).toContain("4 matched"); // not "nothing matched" expect(text).toContain("4 matched"); // not "nothing matched"
expect(text).not.toContain("nothing matched"); expect(text).not.toContain("nothing matched");
expect(result.terminate).toBeUndefined();
}); });
it("read_subsession propagates scope errors so the agent loop reports them", async () => { it("read_subsession propagates scope errors so the agent loop reports them", async () => {
+68 -26
View File
@@ -67,50 +67,51 @@ export interface SubsessionToolDeps {
const SpawnSubsessionParams = Type.Object({ const SpawnSubsessionParams = Type.Object({
prompt: Type.String({ prompt: Type.String({
description: "The first instruction to send to the new tracked subsession.", description: "Initial instruction for the tracked child.",
}), }),
cwd: Type.Optional(Type.String({ cwd: Type.Optional(Type.String({
description: "Working directory for the subsession. Must be a workspace (worktree, or root) of the same project as this session. Defaults to this session's working directory.", description: "Child workspace in the same project (worktree or root); defaults to the parent's directory.",
})), })),
}); });
const ListSubsessionsParams = Type.Object({}); const ListSubsessionsParams = Type.Object({});
const YieldToSubsessionsParams = Type.Object({});
const CheckSubsessionParams = Type.Object({ const CheckSubsessionParams = Type.Object({
sessionId: Type.String({ sessionId: Type.String({
description: "Id of a tracked subsession owned by the calling session, as returned by spawn_subsession or list_subsessions.", description: "Tracked child id from spawn_subsession or list_subsessions.",
}), }),
}); });
const ReadSubsessionParams = Type.Object({ const ReadSubsessionParams = Type.Object({
sessionId: Type.String({ sessionId: Type.String({
description: "Id of a tracked subsession owned by the calling session, as returned by spawn_subsession or list_subsessions.", description: "Tracked child id from spawn_subsession or list_subsessions.",
}), }),
roles: Type.Optional(Type.Array( roles: Type.Optional(Type.Array(
Type.Union([Type.Literal("assistant"), Type.Literal("user"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("custom")]), Type.Union([Type.Literal("assistant"), Type.Literal("user"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("custom")]),
{ description: "Message roles to include. Omit for all roles." }, { description: "Roles to include; omit for all." },
)), )),
include: Type.Optional(Type.Array( include: Type.Optional(Type.Array(
Type.Union([Type.Literal("text"), Type.Literal("thinking"), Type.Literal("tool_call"), Type.Literal("tool_result"), Type.Literal("image")]), Type.Union([Type.Literal("text"), Type.Literal("thinking"), Type.Literal("tool_call"), Type.Literal("tool_result"), Type.Literal("image")]),
{ description: "Content kinds to keep within messages. Omit for all kinds." }, { description: "Content kinds to include; omit for all." },
)), )),
search: Type.Optional(Type.String({ search: Type.Optional(Type.String({
description: "Case-insensitive substring; keep only messages whose text or tool name matches. Always searches full message content, even when maxChars is set.", description: "Case-insensitive text or tool-name substring; searches full content before maxChars truncation.",
})), })),
maxChars: Type.Optional(Type.Integer({ maxChars: Type.Optional(Type.Integer({
minimum: 0, minimum: 0,
description: "Truncate each text/thinking/tool-result value to this many characters; clipped parts are marked '[+N chars truncated]'. Omit for full, untruncated text (there is no default, so truncation only happens when you ask for it).", description: "Maximum characters per text, thinking, or tool-result value; omit for no truncation.",
})), })),
includeToolArgs: Type.Optional(Type.Boolean({ includeToolArgs: Type.Optional(Type.Boolean({
description: "Include raw tool-call arguments (can be large). A compact one-line summary of each call is always shown regardless.", description: "Include raw tool-call arguments; summaries are always included.",
})), })),
before: Type.Optional(Type.Integer({ before: Type.Optional(Type.Integer({
minimum: 0, minimum: 0,
description: "Return only messages before this transcript index; page backward by passing the previous response's 'start'.", description: "Return messages before this index; use the previous start to page backward.",
})), })),
limit: Type.Optional(Type.Integer({ limit: Type.Optional(Type.Integer({
minimum: 1, minimum: 1,
description: "Maximum number of most-recent matching messages to return within the window (returned in chronological order). Defaults to 50.", description: "Maximum recent matches, in chronological order. Defaults to 50.",
})), })),
}); });
@@ -118,6 +119,10 @@ function statusLine(summary: SubsessionSummary): string {
return `- ${summary.sessionId} [${summary.status}] in ${summary.cwd}`; return `- ${summary.sessionId} [${summary.status}] in ${summary.cwd}`;
} }
function workingInspectionGuidance(sessionId: string): string {
return `Subsession ${sessionId} is working; partial output is withheld. Continue independent work, or call yield_to_subsessions alone and last at the join point. Completion notices wake you; do not poll.`;
}
function renderEntry(entry: TranscriptEntry): string { function renderEntry(entry: TranscriptEntry): string {
const header = `#${String(entry.index)} ${entry.role}`; const header = `#${String(entry.index)} ${entry.role}`;
const body = entry.parts.map(renderPart).filter((line) => line !== "").join("\n"); const body = entry.parts.map(renderPart).filter((line) => line !== "").join("\n");
@@ -153,7 +158,7 @@ function renderTranscript(result: SubsessionReadResult): string {
? "no messages matched your filters" ? "no messages matched your filters"
: `no messages in this window (${String(result.matched)} matched outside it)`) : `no messages in this window (${String(result.matched)} matched outside it)`)
: `messages ${String(result.start)}${String(last.index)} of ${String(result.total)} (${String(result.matched)} matched)`; : `messages ${String(result.start)}${String(last.index)} of ${String(result.total)} (${String(result.matched)} matched)`;
const more = result.hasMore ? `\n\nEarlier matching messages exist before index ${String(result.start)}.` : ""; const more = result.hasMore ? ` Earlier matching messages exist before index ${String(result.start)}.` : "";
// Empty entries with matches means the `before` cursor excluded every match // Empty entries with matches means the `before` cursor excluded every match
// (they all sit at index >= before): the agent paged too far back and should // (they all sit at index >= before): the agent paged too far back and should
// raise `before` or omit it, not page back further. // raise `before` or omit it, not page back further.
@@ -162,11 +167,12 @@ function renderTranscript(result: SubsessionReadResult): string {
: (result.matched === 0 : (result.matched === 0
? "(no messages matched the filters)" ? "(no messages matched the filters)"
: `(no messages before index ${String(result.start)}; all ${String(result.matched)} matches have later indexes)`); : `(no messages before index ${String(result.start)}; all ${String(result.matched)} matches have later indexes)`);
return `Subsession ${result.sessionId} [${result.status}] — ${range}:\n\n${body}${more}`; return `Subsession ${result.sessionId} [${result.status}] — ${range}.${more}\n\n--- SUBSESSION TRANSCRIPT: ${result.sessionId} ---\n${body}`;
} }
/** /**
* Tools that let an agent spawn *tracked* child sessions and inspect them. * Tools that let an agent spawn *tracked* child sessions, inspect them, and
* explicitly yield at a join point.
* *
* Unlike `spawn_session` (fire-and-forget peers), a subsession records its * Unlike `spawn_session` (fire-and-forget peers), a subsession records its
* parent in its session header, the parent is notified when it stops working, * parent in its session header, the parent is notified when it stops working,
@@ -178,8 +184,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const spawnTool = defineTool<typeof SpawnSubsessionParams, SpawnSubsessionResult>({ const spawnTool = defineTool<typeof SpawnSubsessionParams, SpawnSubsessionResult>({
name: "spawn_subsession", name: "spawn_subsession",
label: "Spawn subsession", label: "Spawn subsession",
description: "Start a tracked child and return after dispatch. Track required children as pending: continue independent work, then yield at a join point until all have notified completion. Notifications queue while the parent is busy; do not poll for completion.", description: "Start a tracked child and return immediately. Continue independent work, then use yield_to_subsessions at the join point. Completion notices wake you; do not poll.",
promptSnippet: "spawn_subsession: delegate parallel work; yield at a join point until all required children complete.", promptSnippet: "spawn_subsession: tracked parallel work; continue, then join with yield_to_subsessions",
parameters: SpawnSubsessionParams, parameters: SpawnSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId(); const parentSessionId = ctx.sessionManager.getSessionId();
@@ -193,7 +199,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
...(ctx.model === undefined ? {} : { model: ctx.model }), ...(ctx.model === undefined ? {} : { model: ctx.model }),
}); });
return { return {
content: [{ type: "text", text: `Started tracked subsession ${result.sessionId} in ${result.cwd}. Track it as pending and, before finalizing dependent work, yield until all required children have notified completion.` }], content: [{ type: "text", text: `Started tracked subsession ${result.sessionId} in ${result.cwd}. Continue independent work, then join with yield_to_subsessions; do not poll.` }],
details: result, details: result,
}; };
}, },
@@ -202,8 +208,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const listTool = defineTool<typeof ListSubsessionsParams, { subsessions: SubsessionSummary[] }>({ const listTool = defineTool<typeof ListSubsessionsParams, { subsessions: SubsessionSummary[] }>({
name: "list_subsessions", name: "list_subsessions",
label: "List subsessions", label: "List subsessions",
description: "List tracked child sessions owned by the calling session, with each child's current status (working, idle, error, or unknown).", description: "List tracked child statuses. Never yields or changes control flow; do not poll.",
promptSnippet: "list_subsessions: see the tracked child sessions you spawned", promptSnippet: "list_subsessions: inspect child statuses; never yields",
parameters: ListSubsessionsParams, parameters: ListSubsessionsParams,
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId(); const parentSessionId = ctx.sessionManager.getSessionId();
@@ -219,16 +225,19 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const checkTool = defineTool<typeof CheckSubsessionParams, SubsessionCheckResult>({ const checkTool = defineTool<typeof CheckSubsessionParams, SubsessionCheckResult>({
name: "check_subsession", name: "check_subsession",
label: "Check subsession", label: "Check subsession",
description: "Return a tracked subsession's current status, message count, and most recent assistant output.", description: "Get a tracked child's status and latest output. Working output is withheld. Never yields; do not poll.",
promptSnippet: "check_subsession: glance at a subsession's status and latest output", promptSnippet: "check_subsession: inspect child status and available output; never yields",
parameters: CheckSubsessionParams, parameters: CheckSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId(); const parentSessionId = ctx.sessionManager.getSessionId();
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined; const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
const result = await deps.check(parentSessionId, params.sessionId, parentSessionFile); const result = await deps.check(parentSessionId, params.sessionId, parentSessionFile);
const body = result.finalText === "" ? "(no output yet)" : result.finalText; const body = result.finalText === "" ? "(no output yet)" : result.finalText;
const text = result.status === "working"
? workingInspectionGuidance(result.sessionId)
: `Subsession ${result.sessionId} [${result.status}].\n\n--- SUBSESSION OUTPUT: ${result.sessionId} ---\n${body}`;
return { return {
content: [{ type: "text", text: `Subsession ${result.sessionId} [${result.status}]:\n\n${body}` }], content: [{ type: "text", text }],
details: result, details: result,
}; };
}, },
@@ -237,20 +246,53 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const readTool = defineTool<typeof ReadSubsessionParams, SubsessionReadResult>({ const readTool = defineTool<typeof ReadSubsessionParams, SubsessionReadResult>({
name: "read_subsession", name: "read_subsession",
label: "Read subsession", label: "Read subsession",
description: "Return a filtered, paginated transcript of a tracked subsession. Filters select message roles and content kinds, search full message content, optionally include raw tool arguments, and cap or page the returned entries.", description: "Read a tracked child's filtered transcript. Working transcripts are withheld. Never yields; do not poll.",
promptSnippet: "read_subsession: read through a subsession's transcript with filters", promptSnippet: "read_subsession: inspect an available child transcript; never yields",
parameters: ReadSubsessionParams, parameters: ReadSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId(); const parentSessionId = ctx.sessionManager.getSessionId();
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined; const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
const { sessionId, ...query } = params; const { sessionId, ...query } = params;
const result = await deps.read(parentSessionId, sessionId, query, parentSessionFile); const result = await deps.read(parentSessionId, sessionId, query, parentSessionFile);
const text = result.status === "working"
? workingInspectionGuidance(result.sessionId)
: renderTranscript(result);
return { return {
content: [{ type: "text", text: renderTranscript(result) }], content: [{ type: "text", text }],
details: result, details: result,
}; };
}, },
}); });
return [spawnTool, listTool, checkTool, readTool]; const yieldTool = defineTool<typeof YieldToSubsessionsParams, { subsessions: SubsessionSummary[] }>({
name: "yield_to_subsessions",
label: "Yield to subsessions",
description: "At a join point, end this run while tracked children work; completion notices wake you. If none work, continue. Call alone and last; do not poll.",
promptSnippet: "yield_to_subsessions: end the run at a join point; call alone and last",
promptGuidelines: [
"After independent work, yield only at a join point; use spawn_session for fire-and-forget work.",
"Call alone and last; a mixed tool batch may continue the run.",
"Completion notices wake you; do not poll inspection tools.",
],
parameters: YieldToSubsessionsParams,
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId();
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
const subsessions = await deps.list(parentSessionId, parentSessionFile);
const working = subsessions.filter(({ status }) => status === "working");
if (working.length === 0) {
return {
content: [{ type: "text", text: "No tracked subsessions are working; continuing." }],
details: { subsessions },
};
}
return {
content: [{ type: "text", text: `Working: ${working.map(({ sessionId }) => sessionId).join(", ")}. Ending this run; completion notices will wake you.` }],
details: { subsessions },
terminate: true,
};
},
});
return [spawnTool, listTool, checkTool, readTool, yieldTool];
} }