Archived
feat: add explicit tracked subsession yielding
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add an explicit tracked-subsession yield action so parents can end their run at join points and resume from completion notifications without polling or reading partial child output.
|
||||
+26
-14
@@ -525,24 +525,36 @@
|
||||
<h3><code>subsessions</code></h3>
|
||||
<p>
|
||||
Boolean. Beta. Controls whether agents receive the tracked-subsession tools:
|
||||
<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, and
|
||||
<code>read_subsession</code>. Defaults to <code>false</code> and also requires <code>spawnSessions</code>
|
||||
to be enabled.
|
||||
<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>,
|
||||
<code>read_subsession</code>, and <code>yield_to_subsessions</code>. Defaults to <code>false</code> and also
|
||||
requires <code>spawnSessions</code> to be enabled.
|
||||
</p>
|
||||
<p>
|
||||
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 <code>spawn_subsession</code> 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 <code>spawn_subsession</code> 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 <code>spawn_session</code> tool instead.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
<code>list_subsessions</code>, <code>check_subsession</code>, and <code>read_subsession</code> 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
|
||||
<code>yield_to_subsessions</code> 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.
|
||||
</p>
|
||||
<p>
|
||||
A completion notice automatically wakes an idle parent. If the parent is busy, the notice queues until
|
||||
the current turn ends rather than interrupting in-flight work. When multiple children finish at
|
||||
different times, the parent handles each completion and calls <code>yield_to_subsessions</code> again
|
||||
while another child is still working.
|
||||
</p>
|
||||
<p>
|
||||
<code>list_subsessions</code>, <code>check_subsession</code>, and <code>read_subsession</code> never yield
|
||||
or change control flow and are not completion-polling mechanisms. They remain available for deliberate
|
||||
inspection or recovery. While a child is working, agent-facing <code>check_subsession</code> and
|
||||
<code>read_subsession</code> withhold partial output and transcript entries and instead direct the parent
|
||||
to continue independent work or yield at the join point. Once the child is no longer working, its output
|
||||
and transcript are available. Completion notifications, rather than polling inspection tools, are the
|
||||
normal synchronization mechanism.
|
||||
</p>
|
||||
<p>
|
||||
In <strong>Settings → Session daemon</strong>, these keys are saved on the selected machine. Restart the
|
||||
|
||||
+7
-3
@@ -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.
|
||||
|
||||
`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 automatically wakes an idle parent. If the parent is busy, the notice queues until the current turn ends rather than interrupting in-flight work. When multiple children finish at different times, the parent handles each completion and calls `yield_to_subsessions` again while another child is still working.
|
||||
|
||||
`list_subsessions`, `check_subsession`, and `read_subsession` never yield or change control flow and are not completion-polling mechanisms. They remain available for deliberate inspection or recovery. While a child is working, agent-facing `check_subsession` and `read_subsession` withhold partial output and transcript entries and instead direct the parent to continue independent work or yield at the join point. Once the child is no longer working, its output and transcript are available. Completion notifications, rather than polling inspection tools, are the normal synchronization mechanism.
|
||||
|
||||
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",
|
||||
"check_subsession",
|
||||
"read_subsession",
|
||||
"yield_to_subsessions",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,17 @@ function tools(deps: Partial<SubsessionToolDeps>) {
|
||||
if (tool === undefined) throw new Error(`missing tool ${name}`);
|
||||
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 still working, so partial output is unavailable through agent-facing inspection. Continue independent work, or at the join point call yield_to_subsessions alone as the final action in its tool batch. Completion notifications wake the parent automatically; do not poll list_subsessions, check_subsession, or read_subsession.`;
|
||||
}
|
||||
|
||||
function firstText(content: readonly (TextContent | ImageContent)[]): string {
|
||||
@@ -52,27 +62,40 @@ describe("createSubsessionToolDefinitions", () => {
|
||||
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({
|
||||
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.promptSnippet).toBe("spawn_subsession: delegate parallel work; yield at a join point until all required children complete.");
|
||||
expect(spawnTool.description).toBe("Start a tracked child and return after dispatch. Tracked children are join-oriented: continue independent work, then call yield_to_subsessions at the join point. Completion notifications wake the parent automatically; do not poll.");
|
||||
expect(spawnTool.promptSnippet).toBe("spawn_subsession: delegate join-oriented work; continue independently, then use yield_to_subsessions at the join point");
|
||||
|
||||
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 at the join point call yield_to_subsessions rather than polling for completion.");
|
||||
});
|
||||
|
||||
it("keeps subsession inspection tool descriptions capability-oriented", () => {
|
||||
it("distinguishes status inspection from yielding in tool metadata", () => {
|
||||
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.check.description).toBe("Return a tracked subsession's current status, message count, and most recent assistant output.");
|
||||
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.");
|
||||
for (const definition of [definitions.list, definitions.check, definitions.read]) {
|
||||
expect(definition.description).not.toMatch(/use this|do not poll|continue working|start narrow|for just the final|relay/i);
|
||||
}
|
||||
expect(definitions.list.description).toBe("Return tracked child statuses for deliberate inspection. This never yields or changes control flow and is not a completion-polling mechanism.");
|
||||
expect(definitions.list.promptSnippet).toBe("list_subsessions: deliberately inspect tracked child status without yielding");
|
||||
expect(definitions.check.description).toBe("Return status, message count, and latest output for a non-working tracked child. A working child returns guidance instead of partial output. This never yields or changes control flow.");
|
||||
expect(definitions.check.promptSnippet).toBe("check_subsession: inspect a non-working child's latest output without yielding");
|
||||
expect(definitions.read.description).toBe("Return a filtered, paginated transcript for a non-working tracked child. A working child returns guidance instead of partial transcript entries. This never yields or changes control flow.");
|
||||
expect(definitions.read.promptSnippet).toBe("read_subsession: inspect a non-working child's transcript without yielding");
|
||||
});
|
||||
|
||||
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("End the current agent run at a tracked-subsession join point when any child is still working, allowing completion notifications to wake the parent. If none are working, remain active and report that there is nothing to wait for. Call alone as the final action in its tool batch; do not poll.");
|
||||
expect(yieldTool.promptSnippet).toBe("yield_to_subsessions: at a join point, end this run while tracked children work; call alone as the final tool action");
|
||||
expect(yieldTool.promptGuidelines).toEqual([
|
||||
"Use yield_to_subsessions only at a join point after all independent parent work is done; tracked subsessions are join-oriented, while optional fire-and-forget work belongs in spawn_session.",
|
||||
"Call yield_to_subsessions alone as the final action in its tool batch. Pi ends the run only when every finalized result in that batch is terminating.",
|
||||
"Do not poll list_subsessions, check_subsession, or read_subsession for completion; completion notifications wake the parent automatically.",
|
||||
]);
|
||||
});
|
||||
|
||||
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" },
|
||||
] });
|
||||
expect(firstText(result.content)).toContain("child-1 [working]");
|
||||
expect(result.terminate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("list_subsessions reports an empty state", async () => {
|
||||
const { list: listTool } = tools({ list: vi.fn(() => Promise.resolve([])) });
|
||||
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.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("Yielding to working tracked subsessions: child-1, child-3. The current agent run is ending; completion notifications will wake the parent as children stop working.");
|
||||
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 currently working. Nothing was yielded; continue without waiting.");
|
||||
expect(result.terminate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("check_subsession scopes by parent and returns the final result", async () => {
|
||||
@@ -122,6 +184,32 @@ describe("createSubsessionToolDefinitions", () => {
|
||||
expect(check).toHaveBeenCalledWith("parent-1", "child-1", "/sessions/parent-1.jsonl");
|
||||
expect(result.details).toMatchObject({ sessionId: "child-1", status: "idle", finalText: "all done" });
|
||||
expect(firstText(result.content)).toContain("all 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\nchild failed");
|
||||
expect(result.terminate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("check_subsession propagates scope errors so the agent loop reports them", async () => {
|
||||
@@ -145,6 +233,25 @@ describe("createSubsessionToolDefinitions", () => {
|
||||
expect(read).toHaveBeenCalledWith("parent-1", "child-1", { roles: ["assistant"], maxChars: 200 }, "/sessions/parent-1.jsonl");
|
||||
expect(result.details).toMatchObject({ sessionId: "child-1", matched: 1 });
|
||||
expect(firstText(result.content)).toContain("the 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 () => {
|
||||
@@ -165,6 +272,7 @@ describe("createSubsessionToolDefinitions", () => {
|
||||
expect(text).toContain("command"); // raw args surfaced in text, not only details
|
||||
expect(text).toContain("ls -la");
|
||||
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 () => {
|
||||
@@ -178,6 +286,7 @@ describe("createSubsessionToolDefinitions", () => {
|
||||
const text = firstText(result.content);
|
||||
expect(text).toContain("4 matched"); // not "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 () => {
|
||||
|
||||
@@ -75,6 +75,7 @@ const SpawnSubsessionParams = Type.Object({
|
||||
});
|
||||
|
||||
const ListSubsessionsParams = Type.Object({});
|
||||
const YieldToSubsessionsParams = Type.Object({});
|
||||
|
||||
const CheckSubsessionParams = Type.Object({
|
||||
sessionId: Type.String({
|
||||
@@ -118,6 +119,10 @@ function statusLine(summary: SubsessionSummary): string {
|
||||
return `- ${summary.sessionId} [${summary.status}] in ${summary.cwd}`;
|
||||
}
|
||||
|
||||
function workingInspectionGuidance(sessionId: string): string {
|
||||
return `Subsession ${sessionId} is still working, so partial output is unavailable through agent-facing inspection. Continue independent work, or at the join point call yield_to_subsessions alone as the final action in its tool batch. Completion notifications wake the parent automatically; do not poll list_subsessions, check_subsession, or read_subsession.`;
|
||||
}
|
||||
|
||||
function renderEntry(entry: TranscriptEntry): string {
|
||||
const header = `#${String(entry.index)} ${entry.role}`;
|
||||
const body = entry.parts.map(renderPart).filter((line) => line !== "").join("\n");
|
||||
@@ -166,7 +171,8 @@ function renderTranscript(result: SubsessionReadResult): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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>({
|
||||
name: "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.",
|
||||
promptSnippet: "spawn_subsession: delegate parallel work; yield at a join point until all required children complete.",
|
||||
description: "Start a tracked child and return after dispatch. Tracked children are join-oriented: continue independent work, then call yield_to_subsessions at the join point. Completion notifications wake the parent automatically; do not poll.",
|
||||
promptSnippet: "spawn_subsession: delegate join-oriented work; continue independently, then use yield_to_subsessions at the join point",
|
||||
parameters: SpawnSubsessionParams,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const parentSessionId = ctx.sessionManager.getSessionId();
|
||||
@@ -193,7 +199,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
|
||||
...(ctx.model === undefined ? {} : { model: ctx.model }),
|
||||
});
|
||||
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 at the join point call yield_to_subsessions rather than polling for completion.` }],
|
||||
details: result,
|
||||
};
|
||||
},
|
||||
@@ -202,8 +208,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
|
||||
const listTool = defineTool<typeof ListSubsessionsParams, { subsessions: SubsessionSummary[] }>({
|
||||
name: "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).",
|
||||
promptSnippet: "list_subsessions: see the tracked child sessions you spawned",
|
||||
description: "Return tracked child statuses for deliberate inspection. This never yields or changes control flow and is not a completion-polling mechanism.",
|
||||
promptSnippet: "list_subsessions: deliberately inspect tracked child status without yielding",
|
||||
parameters: ListSubsessionsParams,
|
||||
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
||||
const parentSessionId = ctx.sessionManager.getSessionId();
|
||||
@@ -219,16 +225,19 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
|
||||
const checkTool = defineTool<typeof CheckSubsessionParams, SubsessionCheckResult>({
|
||||
name: "check_subsession",
|
||||
label: "Check subsession",
|
||||
description: "Return a tracked subsession's current status, message count, and most recent assistant output.",
|
||||
promptSnippet: "check_subsession: glance at a subsession's status and latest output",
|
||||
description: "Return status, message count, and latest output for a non-working tracked child. A working child returns guidance instead of partial output. This never yields or changes control flow.",
|
||||
promptSnippet: "check_subsession: inspect a non-working child's latest output without yielding",
|
||||
parameters: CheckSubsessionParams,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const parentSessionId = ctx.sessionManager.getSessionId();
|
||||
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
||||
const result = await deps.check(parentSessionId, params.sessionId, parentSessionFile);
|
||||
const body = result.finalText === "" ? "(no output yet)" : result.finalText;
|
||||
const text = result.status === "working"
|
||||
? workingInspectionGuidance(result.sessionId)
|
||||
: `Subsession ${result.sessionId} [${result.status}]:\n\n${body}`;
|
||||
return {
|
||||
content: [{ type: "text", text: `Subsession ${result.sessionId} [${result.status}]:\n\n${body}` }],
|
||||
content: [{ type: "text", text }],
|
||||
details: result,
|
||||
};
|
||||
},
|
||||
@@ -237,20 +246,53 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
|
||||
const readTool = defineTool<typeof ReadSubsessionParams, SubsessionReadResult>({
|
||||
name: "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.",
|
||||
promptSnippet: "read_subsession: read through a subsession's transcript with filters",
|
||||
description: "Return a filtered, paginated transcript for a non-working tracked child. A working child returns guidance instead of partial transcript entries. This never yields or changes control flow.",
|
||||
promptSnippet: "read_subsession: inspect a non-working child's transcript without yielding",
|
||||
parameters: ReadSubsessionParams,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const parentSessionId = ctx.sessionManager.getSessionId();
|
||||
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
||||
const { sessionId, ...query } = params;
|
||||
const result = await deps.read(parentSessionId, sessionId, query, parentSessionFile);
|
||||
const text = result.status === "working"
|
||||
? workingInspectionGuidance(result.sessionId)
|
||||
: renderTranscript(result);
|
||||
return {
|
||||
content: [{ type: "text", text: renderTranscript(result) }],
|
||||
content: [{ type: "text", text }],
|
||||
details: result,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return [spawnTool, listTool, checkTool, readTool];
|
||||
const yieldTool = defineTool<typeof YieldToSubsessionsParams, { subsessions: SubsessionSummary[] }>({
|
||||
name: "yield_to_subsessions",
|
||||
label: "Yield to subsessions",
|
||||
description: "End the current agent run at a tracked-subsession join point when any child is still working, allowing completion notifications to wake the parent. If none are working, remain active and report that there is nothing to wait for. Call alone as the final action in its tool batch; do not poll.",
|
||||
promptSnippet: "yield_to_subsessions: at a join point, end this run while tracked children work; call alone as the final tool action",
|
||||
promptGuidelines: [
|
||||
"Use yield_to_subsessions only at a join point after all independent parent work is done; tracked subsessions are join-oriented, while optional fire-and-forget work belongs in spawn_session.",
|
||||
"Call yield_to_subsessions alone as the final action in its tool batch. Pi ends the run only when every finalized result in that batch is terminating.",
|
||||
"Do not poll list_subsessions, check_subsession, or read_subsession for completion; completion notifications wake the parent automatically.",
|
||||
],
|
||||
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 currently working. Nothing was yielded; continue without waiting." }],
|
||||
details: { subsessions },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: `Yielding to working tracked subsessions: ${working.map(({ sessionId }) => sessionId).join(", ")}. The current agent run is ending; completion notifications will wake the parent as children stop working.` }],
|
||||
details: { subsessions },
|
||||
terminate: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return [spawnTool, listTool, checkTool, readTool, yieldTool];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user