fix: delimit subsession output from guidance

This commit is contained in:
Federico Jaramillo Martinez
2026-07-14 00:04:53 +02:00
parent 0761e1509a
commit c0465c4a5c
7 changed files with 17 additions and 16 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"@jmfederico/pi-web": patch "@jmfederico/pi-web": patch
--- ---
Add explicit tracked-subsession yielding, with wake-up notices that identify children still working so parents can continue or yield again without polling or reading partial output. Add explicit tracked-subsession yielding with no-poll wake-up guidance, remaining-child status, and clear boundaries around child output.
+2 -1
View File
@@ -552,7 +552,8 @@
or change control flow. They are for deliberate inspection or recovery, not completion polling. While a 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 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 output and direct the parent to continue independent work or yield at the join point. Output becomes
available when the child stops. 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
+1 -1
View File
@@ -189,7 +189,7 @@ At a join point, after finishing its independent work, the parent calls `yield_t
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. 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. `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.
@@ -804,7 +804,7 @@ describe("PiSessionService", () => {
await new Promise((resolve) => setTimeout(resolve, 20)); await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage[0]?.message.content).toBe( expect(parent.calls.sendCustomMessage[0]?.message.content).toBe(
"Subsession child-1 stopped working (idle). Latest output:\n\n(no output)\n\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.", "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.session.isStreaming = false;
@@ -812,7 +812,7 @@ describe("PiSessionService", () => {
await new Promise((resolve) => setTimeout(resolve, 20)); await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage[1]?.message.content).toBe( expect(parent.calls.sendCustomMessage[1]?.message.content).toBe(
"Subsession child-2 stopped working (idle). Latest output:\n\n(no output)\n\nNo other tracked subsessions are working.", "Subsession child-2 stopped working (idle).\nNo other tracked subsessions are working.\n\n--- SUBSESSION OUTPUT: child-2 ---\n(no output)",
); );
await service.dispose(); await service.dispose();
}); });
+1 -1
View File
@@ -931,7 +931,7 @@ export class PiSessionService {
const next = workingIds.length === 0 const next = workingIds.length === 0
? "No other tracked subsessions are working." ? "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.`; : `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}). Latest output:\n\n${preview}\n\n${next}`; 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);
} }
@@ -183,7 +183,7 @@ 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(); expect(result.terminate).toBeUndefined();
}); });
@@ -208,7 +208,7 @@ describe("createSubsessionToolDefinitions", () => {
const result = await checkTool.execute("call-error-check", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", undefined)); 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(firstText(result.content)).toBe("Subsession child-1 [error].\n\n--- SUBSESSION OUTPUT: child-1 ---\nchild failed");
expect(result.terminate).toBeUndefined(); expect(result.terminate).toBeUndefined();
}); });
@@ -224,15 +224,15 @@ 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(); expect(result.terminate).toBeUndefined();
}); });
+3 -3
View File
@@ -158,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.
@@ -167,7 +167,7 @@ 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}`;
} }
/** /**
@@ -235,7 +235,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const body = result.finalText === "" ? "(no output yet)" : result.finalText; const body = result.finalText === "" ? "(no output yet)" : result.finalText;
const text = result.status === "working" const text = result.status === "working"
? workingInspectionGuidance(result.sessionId) ? workingInspectionGuidance(result.sessionId)
: `Subsession ${result.sessionId} [${result.status}]:\n\n${body}`; : `Subsession ${result.sessionId} [${result.status}].\n\n--- SUBSESSION OUTPUT: ${result.sessionId} ---\n${body}`;
return { return {
content: [{ type: "text", text }], content: [{ type: "text", text }],
details: result, details: result,