renamed -> caller_ping to ask_question, questions don't end subagent sessions anymore, subagent sessions stay open when waiting for their own subagents to finish

This commit is contained in:
Amos
2026-06-28 18:02:30 +03:00
parent 8c707c6179
commit 20332d854c
7 changed files with 359 additions and 153 deletions
+13 -13
View File
@@ -20,8 +20,7 @@ export type SubagentActivityEvent =
| "tool_execution_update"
| "tool_result"
| "tool_execution_end"
| "caller_ping"
| "subagent_done"
| "ask_question"
| "session_shutdown";
export interface SubagentActivityState {
@@ -70,8 +69,7 @@ export interface SubagentActivityRecorder {
toolExecutionUpdate(toolCallId?: string, toolName?: string): void;
toolResult(toolCallId?: string, toolName?: string): void;
toolExecutionEnd(toolCallId?: string, toolName?: string): void;
callerPing(): void;
subagentDone(): void;
askQuestion(): void;
sessionShutdown(reason: SubagentShutdownReason): void;
}
@@ -95,8 +93,7 @@ const KNOWN_EVENTS = new Set<SubagentActivityEvent>([
"tool_execution_update",
"tool_result",
"tool_execution_end",
"caller_ping",
"subagent_done",
"ask_question",
"session_shutdown",
]);
const MAX_ACTIVITY_STRING_LENGTH = 200;
@@ -239,8 +236,7 @@ function createNoopRecorder(): SubagentActivityRecorder {
toolExecutionUpdate() {},
toolResult() {},
toolExecutionEnd() {},
callerPing() {},
subagentDone() {},
askQuestion() {},
sessionShutdown() {},
};
}
@@ -497,11 +493,15 @@ export function createSubagentActivityRecorder(params: {
refreshActiveScope(current);
}, "immediate");
},
callerPing() {
markDone("caller_ping");
},
subagentDone() {
markDone("subagent_done");
askQuestion() {
// The subagent paused to ask the orchestrator a question. Park it in the
// "waiting" phase (do NOT disable the recorder) so the status widget shows
// it as waiting and recording resumes when the answer arrives.
record("ask_question", (current, observedAt) => {
clearActiveState(current);
current.phase = "waiting";
current.waitingSince = observedAt;
}, "immediate");
},
sessionShutdown(reason) {
if (reason === "quit") markDone("session_shutdown");
+11 -16
View File
@@ -1256,28 +1256,23 @@ export function closeSurface(surface: string): void {
export interface PollResult {
/** How the subagent exited */
reason: "done" | "ping" | "sentinel" | "error";
reason: "done" | "sentinel" | "error";
/** Shell exit code (from sentinel). 0 for file-based exits. */
exitCode: number;
/** Ping data if reason is "ping" */
ping?: { name: string; message: string };
/** Error message if reason is "error" (auto-retry exhausted, provider overload, etc.) */
errorMessage?: string;
}
/**
* Interpret an `.exit` sidecar payload (written by subagent_done / caller_ping /
* the error path in subagent-done.ts). Centralized so both the fast and slow
* paths in pollForExit decode the payload the same way.
* Interpret an `.exit` sidecar payload (written by the error path in
* subagent-done.ts). Centralized so both the fast and slow paths in
* pollForExit decode the payload the same way. Clean completions write no
* sidecar and are detected via the terminal sentinel instead.
*
* Note: ask_question does NOT write a `.exit` sidecar — it keeps the session
* open and signals the parent via a separate `.ask` file (see deliverPendingQuestion).
*/
function interpretExitSidecar(data: any): PollResult {
if (data?.type === "ping") {
return {
reason: "ping",
exitCode: 0,
ping: { name: data.name, message: data.message },
};
}
if (data?.type === "error") {
const errorMessage =
typeof data.errorMessage === "string" && data.errorMessage.trim() !== ""
@@ -1292,8 +1287,8 @@ export const __pollForExitTest__ = { interpretExitSidecar };
/**
* Poll until the subagent exits. Checks for a `.exit` sidecar file first
* (written by subagent_done / caller_ping), falling back to the terminal
* sentinel for crash detection.
* (written by the error path), falling back to the terminal sentinel for
* clean-completion and crash detection.
*/
export async function pollForExit(
surface: string,
@@ -1312,7 +1307,7 @@ export async function pollForExit(
throw new Error("Aborted while waiting for subagent to finish");
}
// Fast path: check for .exit sidecar file (written by subagent_done / caller_ping)
// Fast path: check for .exit sidecar file (written by the error path)
if (options.sessionFile) {
try {
const exitFile = `${options.sessionFile}.exit`;
+69 -57
View File
@@ -548,7 +548,6 @@ interface SubagentResult {
error?: string;
/** Provider/agent error message when auto-retry exhausted (overload, rate limit, etc.). */
errorMessage?: string;
ping?: { name: string; message: string };
/** Aggregate usage/model/tool stats parsed from the completed session file. */
stats?: SessionStats;
}
@@ -588,10 +587,21 @@ interface RunningSubagent {
/** All currently running subagents, keyed by id. */
const runningSubagents = new Map<string, RunningSubagent>();
// When this extension is loaded inside a subagent that itself spawns children
// (e.g. a worker delegating to scout/researcher), `subagent-done.ts` runs in the
// same process and needs to know whether this session still has children in
// flight — so it can suppress auto-exit and keep the session open until they all
// report back. Expose a live count through a process-global symbol that both
// modules share. (subagent-done.ts reads it; if absent it assumes zero.)
const RUNNING_CHILDREN_COUNT_KEY = Symbol.for("pi-subagents/running-children-count");
(globalThis as any)[RUNNING_CHILDREN_COUNT_KEY] = () => runningSubagents.size;
// ── Widget management ──
/** Latest ExtensionContext from session_start, used for widget updates. */
let latestCtx: ExtensionContext | null = null;
/** Latest ExtensionAPI, used to deliver ask_question notifications from the watcher. */
let latestPi: ExtensionAPI | null = null;
/** Interval timer for widget re-renders. */
let widgetInterval: ReturnType<typeof setInterval> | null = null;
@@ -731,7 +741,7 @@ function updateWidget() {
* first positional message so that /skill: args land in messages[1..] and arrive
* as standalone prompts in the child session.
*/
const SUBAGENT_CONTROL_TOOLS = ["caller_ping", "subagent_done"] as const;
const SUBAGENT_CONTROL_TOOLS = ["ask_question"] as const;
/**
* Build the child --tools allowlist.
@@ -739,7 +749,7 @@ const SUBAGENT_CONTROL_TOOLS = ["caller_ping", "subagent_done"] as const;
* Pi 0.70+ applies --tools to built-in, extension, and custom tools. If a
* subagent definition restricts tools to e.g. "read,bash,write", the child
* control tools from subagent-done.ts would otherwise be hidden, leaving a
* manually resumed or user-touched subagent unable to call subagent_done.
* manually resumed or user-touched subagent unable to call ask_question.
*/
function buildSubagentToolAllowlist(
effectiveTools?: string,
@@ -1113,11 +1123,11 @@ async function launchSubagent(
// Only full-context fork mode inherits prior conversation state.
// Blank-session modes need the wrapper instructions and artifact-backed handoff.
const modeHint = agentDefs?.autoExit
? "Complete your task autonomously."
: "Complete your task. When finished, call the subagent_done tool. The user can interact with you at any time.";
? "Complete your task autonomously. When you are finished, simply stop — your session ends automatically."
: "Complete your task. The user can interact with you at any time, and the session ends when the user exits the pane.";
const summaryInstruction = agentDefs?.autoExit
? "Your FINAL assistant message should summarize what you accomplished."
: "Your FINAL assistant message (before calling subagent_done or before the user exits) should summarize what you accomplished.";
: "Your FINAL assistant message (before the user exits) should summarize what you accomplished.";
// An agent with a non-empty subagent_agents list is granted the spawning
// toolset and may only spawn the listed agents (enforced via PI_SUBAGENT_ALLOWED).
const grantSpawning = !!(agentDefs?.subagentAgents && agentDefs.subagentAgents.length > 0);
@@ -1377,6 +1387,50 @@ function copyClaudeSession(sentinelFile: string): string | null {
}
}
/**
* Detect an `ask_question` signal from a still-running subagent and notify the
* orchestrator without ending the subagent. Each subagent has its own
* `${sessionFile}.ask` file and its own watcher, so parallel questions from
* multiple subagents are delivered independently. The file is deleted after
* delivery so it fires once per question (a subagent may ask again later).
*/
function deliverPendingQuestion(running: RunningSubagent): void {
const askFile = `${running.sessionFile}.ask`;
let payload: any = null;
try {
if (!existsSync(askFile)) return;
payload = JSON.parse(readFileSync(askFile, "utf-8"));
} catch {
// Malformed/partway-written file — drop it and move on.
}
try {
unlinkSync(askFile);
} catch {}
if (!payload?.question) return;
const name = running.name; // unique (deduped at spawn) — used to target the reply
const sessionId = existsSync(running.sessionFile) ? getSessionId(running.sessionFile) : null;
const elapsed = Math.floor((Date.now() - running.startTime) / 1000);
const replyHint = sessionId
? `\n\nReply with subagent_message({ name: "${name}", message: "…" }) while it runs (or sessionId: "${sessionId}" after it exits). It stays open until you reply.`
: `\n\nReply with subagent_message({ name: "${name}", message: "…" }). It stays open until you reply.`;
latestPi?.sendMessage(
{
customType: "subagent_question",
content: `Sub-agent "${name}" asks (${formatElapsed(elapsed)}):\n\n${payload.question}${replyHint}`,
display: true,
details: {
name,
agent: running.agent,
question: payload.question,
...(sessionId ? { sessionId } : {}),
},
},
{ triggerTurn: true, deliverAs: "steer" },
);
}
async function watchSubagent(
running: RunningSubagent,
signal: AbortSignal,
@@ -1390,6 +1444,7 @@ async function watchSubagent(
sentinelFile: running.sentinelFile,
onTick() {
observeRunningSubagent(running);
deliverPendingQuestion(running);
},
});
@@ -1464,7 +1519,6 @@ async function watchSubagent(
...(subagentSessionId ? { sessionId: subagentSessionId } : {}),
exitCode: result.exitCode,
elapsed,
ping: result.ping,
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
...(stats ? { stats } : {}),
};
@@ -1497,6 +1551,7 @@ async function watchSubagent(
}
export default function subagentsExtension(pi: ExtensionAPI) {
latestPi = pi;
// Capture the UI context for widget updates
pi.on("session_start", (_event, ctx) => {
latestCtx = ctx;
@@ -1666,30 +1721,6 @@ export default function subagentsExtension(pi: ExtensionAPI) {
.then((result) => {
updateWidget(); // reflect removal from Map immediately
if (result.ping) {
// Subagent is requesting help — steer a ping with its session id so
// the orchestrator can reply via subagent_message.
const sessionRef = result.sessionId
? `\n\nSession id: ${result.sessionId}\nReply with subagent_message({ name: "${result.ping.name}", message: "…" }) while it runs, or sessionId after it exits.`
: "";
pi.sendMessage(
{
customType: "subagent_ping",
content: `Sub-agent "${result.ping.name}" needs help (${formatElapsed(result.elapsed)}):\n\n${result.ping.message}${sessionRef}`,
display: true,
details: {
name: result.ping.name,
message: result.ping.message,
agent: running.agent,
sessionFile: result.sessionFile,
...(result.sessionId ? { sessionId: result.sessionId } : {}),
},
},
{ triggerTurn: true, deliverAs: "steer" },
);
return;
}
const presentation = resolveResultPresentation(result, running.name);
pi.sendMessage(
@@ -2091,25 +2122,6 @@ export default function subagentsExtension(pi: ExtensionAPI) {
.then((result) => {
updateWidget();
if (result.ping) {
const sessionRef = `\n\nSession id: ${resumedSessionId}\nReply with subagent_message({ name: "${result.ping.name}", message: "…" }) while it runs, or sessionId after it exits.`;
pi.sendMessage(
{
customType: "subagent_ping",
content: `Sub-agent "${result.ping.name}" needs help (${formatElapsed(result.elapsed)}):\n\n${result.ping.message}${sessionRef}`,
display: true,
details: {
name: result.ping.name,
message: result.ping.message,
sessionFile: sessionPath,
sessionId: resumedSessionId,
},
},
{ triggerTurn: true, deliverAs: "steer" },
);
return;
}
const allEntries = getNewEntries(sessionPath, entryCountBefore);
const summary = findLastAssistantMessage(allEntries) ??
(result.errorMessage
@@ -2338,8 +2350,8 @@ export default function subagentsExtension(pi: ExtensionAPI) {
};
});
// ── subagent_ping message renderer ──
pi.registerMessageRenderer("subagent_ping", (message, options, theme) => {
// ── subagent_question message renderer ──
pi.registerMessageRenderer("subagent_question", (message, options, theme) => {
const details = message.details as any;
if (!details) return undefined;
@@ -2350,19 +2362,19 @@ export default function subagentsExtension(pi: ExtensionAPI) {
const bgFn = (text: string) => theme.bg("toolSuccessBg", text);
const icon = theme.fg("accent", "?");
const header = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag} ${theme.fg("dim", "— needs help")}`;
const header = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag} ${theme.fg("dim", "— asks a question")}`;
const contentLines = [header];
if (options.expanded) {
contentLines.push("");
contentLines.push(details.message ?? "");
if (details.sessionFile) {
contentLines.push(details.question ?? "");
if (details.sessionId) {
contentLines.push("");
contentLines.push(theme.fg("dim", `Session: ${details.sessionFile}`));
contentLines.push(theme.fg("dim", `Session id: ${details.sessionId}`));
}
} else {
const preview = (details.message ?? "").split("\n")[0].slice(0, width - 10);
const preview = (details.question ?? "").split("\n")[0].slice(0, width - 10);
contentLines.push(theme.fg("dim", preview));
contentLines.push(theme.fg("muted", keyHint("app.tools.expand", "to expand")));
}
+104 -39
View File
@@ -1,7 +1,16 @@
/**
* Extension loaded into sub-agents.
* - Shows agent identity + available tools as a styled widget above the editor (toggle with Ctrl+Alt+O)
* - Provides a `subagent_done` tool for autonomous agents to self-terminate
* - Provides an `ask_question` tool for asking the parent orchestrator a question
*
* Subagents do NOT self-terminate via a tool. Auto-exit agents shut down
* automatically when their agent loop ends (see the `agent_end` handler);
* interactive agents end when the human exits the pane.
*
* `ask_question` keeps the session OPEN: it writes a `${sessionFile}.ask`
* signal the parent's watcher picks up, parks the session in a "waiting" state
* (auto-exit is suppressed for that turn via `awaitingAnswer`), and the parent
* replies with subagent_message — which lands as the subagent's next turn.
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Box, Text } from "@mariozechner/pi-tui";
@@ -13,6 +22,32 @@ export function shouldMarkUserTookOver(agentStarted: boolean): boolean {
return agentStarted;
}
/**
* Number of child subagents this session itself still has in flight.
*
* When this extension is loaded inside a subagent that can spawn its own
* children (e.g. a worker delegating to scout/researcher), `index.ts` runs in
* the same process and publishes a live count through a shared process-global
* symbol. A subagent that spawns children and then writes a "waiting for
* results" message would otherwise auto-exit the instant that turn ends —
* killing the session before its children report back. Reading this count lets
* `agent_end` keep the session open until every child has finished and its
* result has been delivered.
*
* Returns 0 when the spawning tools aren't loaded (scout/researcher, or a
* standalone session), so those agents auto-exit exactly as before.
*/
export function runningChildrenCount(): number {
const fn = (globalThis as any)[Symbol.for("pi-subagents/running-children-count")];
if (typeof fn !== "function") return 0;
try {
const n = fn();
return typeof n === "number" && n > 0 ? n : 0;
} catch {
return 0;
}
}
export function shouldAutoExitOnAgentEnd(
_userTookOver: boolean,
messages: any[] | undefined,
@@ -143,6 +178,10 @@ export default function (pi: ExtensionAPI) {
let userTookOver = false;
let agentStarted = false;
// Set when ask_question is called; suppresses auto-exit for that turn so the
// session stays open while it waits for the orchestrator's reply. Cleared at
// the start of the next turn (when the reply lands).
let awaitingAnswer = false;
// Show widget + status bar on session start
pi.on("session_start", (_event, ctx) => {
@@ -168,12 +207,27 @@ export default function (pi: ExtensionAPI) {
pi.on("agent_start", () => {
agentStarted = true;
// A new turn is starting — any pending ask_question has now been answered
// (or superseded), so let auto-exit resume normally when this turn ends.
awaitingAnswer = false;
recorder.agentStart();
});
pi.on("agent_end", (event, ctx) => {
const messages = (event as any).messages as any[] | undefined;
const shouldExit = autoExit && shouldAutoExitOnAgentEnd(userTookOver, messages);
// Never shut down while this session still has work in flight:
// - awaitingAnswer: an ask_question is pending the orchestrator's reply.
// - runningChildrenCount(): this subagent spawned its own children and is
// waiting for their results (delivered as steered turns). Exiting now
// would strand those children and drop their results.
// In both cases the session parks as `waiting` and resumes when the next
// turn lands.
const hasPendingChildren = runningChildrenCount() > 0;
const shouldExit =
!awaitingAnswer &&
!hasPendingChildren &&
autoExit &&
shouldAutoExitOnAgentEnd(userTookOver, messages);
if (shouldExit) {
// Surface stopReason: "error" turns (auto-retry exhausted, provider
@@ -266,59 +320,70 @@ export default function (pi: ExtensionAPI) {
});
pi.registerTool({
name: "caller_ping",
label: "Caller Ping",
name: "ask_question",
label: "ask_question",
description:
"Send a help request to the parent agent and exit this session. " +
"The parent will be notified with your message and can resume this session with a response. " +
"Use when you're stuck, need clarification, or need the parent to take action.",
"Ask the orchestrator (the parent agent that spawned you) a single question and pause until they reply. " +
"Use this when requirements are ambiguous, a decision would materially affect your work, you're blocked, " +
"or you need information or confirmation only the orchestrator has. Prefer asking over guessing. " +
"Your session stays open while you wait — the answer arrives as your next message, then you continue. " +
"Ask exactly one question per call; make separate calls for unrelated questions.",
promptSnippet:
"Use this tool to ask the orchestrator one clarifying, missing-requirement, preference, or decision question before continuing — instead of guessing.",
promptGuidelines: [
"Ask exactly one question per tool call.",
"If you need answers to multiple things, make separate ask_question calls instead of bundling them.",
"Prefer this tool over guessing when requirements, preferences, or implementation choices are unclear.",
"Use it when multiple valid paths exist and the right one depends on the orchestrator's intent.",
"Give enough context in the question that the orchestrator can answer without re-reading your whole task.",
"After asking, stop and wait — the reply will arrive as your next message.",
],
parameters: Type.Object({
message: Type.String({ description: "What you need help with" }),
question: Type.String({
description:
"The single freeform question to ask the orchestrator. Include enough context to answer it directly.",
}),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const sessionFile = process.env.PI_SUBAGENT_SESSION;
if (!sessionFile) {
throw new Error(
"caller_ping is only available in subagent contexts. " +
"ask_question is only available in subagent contexts. " +
"PI_SUBAGENT_SESSION environment variable is not set.",
);
}
recorder.callerPing();
const exitData = {
type: "ping" as const,
// Keep the session open: suppress auto-exit for this turn and park in the
// "waiting" phase. The parent's watcher picks up the `.ask` signal and
// notifies the orchestrator, who replies via subagent_message.
awaitingAnswer = true;
recorder.askQuestion();
const askData = {
name: process.env.PI_SUBAGENT_NAME ?? "subagent",
message: params.message,
agent: process.env.PI_SUBAGENT_AGENT ?? "",
question: params.question,
};
writeFileSync(`${sessionFile}.exit`, JSON.stringify(exitData));
writeFileSync(`${sessionFile}.ask`, JSON.stringify(askData));
ctx.shutdown();
return {
content: [{ type: "text", text: "Ping sent. Session will exit and parent will be notified." }],
details: {},
content: [
{
type: "text",
text:
"Question sent to the orchestrator. Stop here and wait — do not continue working or " +
"assume an answer. Their reply will arrive as your next message.",
},
],
details: { question: params.question },
};
},
renderCall(args, theme) {
const text =
theme.fg("toolTitle", theme.bold("ask_question ")) +
theme.fg("muted", String((args as any).question ?? ""));
return new Text(text, 0, 0);
},
});
pi.registerTool({
name: "subagent_done",
label: "Subagent Done",
description:
"Call this tool when you have completed your task. " +
"It will close this session and return your results to the main session. " +
"Your LAST assistant message before calling this becomes the summary returned to the caller.",
parameters: Type.Object({}),
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
const sessionFile = process.env.PI_SUBAGENT_SESSION;
recorder.subagentDone();
if (sessionFile) {
writeFileSync(`${sessionFile}.exit`, JSON.stringify({ type: "done" }));
}
ctx.shutdown();
return {
content: [{ type: "text", text: "Shutting down subagent session." }],
details: {},
};
},
});
}