Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
- Fixed assistant Markdown headings and fenced code blocks appearing with literal source delimiters in the interactive transcript.
- Fixed extension TUI method wrappers recursing indefinitely when delegating to the original method ([#7731](https://github.com/earendil-works/pi/issues/7731)).
- Fixed right-click not pasting clipboard text in fullscreen mode on Windows.
- Fixed sub-agent spawning bypassing execution-mutating parent extension hooks by rejecting incompatible parent policies before child creation.

## [0.84.0] - 2026-08-06

Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,8 @@ Root sessions run at most four children concurrently by default.
Set `subagents: { maxConcurrent: number }` to configure that limit, or `subagents: false` to disable child agents and omit all three coordination tools.
The names `spawn_agent`, `agent_action`, `agents`, and `escalate_to_parent` are reserved and cannot be supplied through `customTools`.

Child sessions intentionally do not load parent extensions. To prevent an extension policy bypass, `spawn_agent` fails closed when the root has a registered handler for any execution-mutating hook: `resources_discover`, `input`, `context`, `before_agent_start`, `before_provider_request`, `before_provider_headers`, `session_before_compact`, `tool_call`, `tool_result`, or `message_end`. The error lists every incompatible hook in that order. Remove those handlers before spawning, or set `subagents: false`; observation-only hooks and non-spawn child actions remain available.

Each live root-owned child also receives the child-only `escalate_to_parent` tool; roots, standalone sessions, and restored children that have not been explicitly woken do not receive it.
It accepts one non-empty message of at most 1,000 characters for material information that could redirect or interrupt the parent, and the child must still return a complete final response.
Multiple calls are allowed during one live run, subject to a fixed 8,000-character aggregate limit for accepted report text not yet admitted to parent context.
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ It displays the current in-memory roster without adding the command or roster to
The enclosing parent prompt remains active after the parent attempts to finish.
Diffex freezes the children started by that prompt, waits until all of them finish, fail, or are interrupted, and displays a deterministic notice as each child terminates.

Children do not load parent extensions. To prevent them from bypassing parent execution policy, spawning fails before child creation when the parent registers any of these hooks: `resources_discover`, `input`, `context`, `before_agent_start`, `before_provider_request`, `before_provider_headers`, `session_before_compact`, `tool_call`, `tool_result`, or `message_end`. The rejection lists the incompatible hooks in that order. Remove those handlers to spawn children; observation-only hooks and existing-child message, interrupt, and status actions remain available.

### Child Escalations

A running root-owned child can use its child-only `escalate_to_parent` tool to report a material blocker, risk, contradiction, or decision that could change the parent's plan.
Expand Down
18 changes: 17 additions & 1 deletion packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,18 @@ const CURRENT_PLAN_RESTORATION_CUSTOM_TYPE = "diffex.current-plan-restoration";
const CURRENT_PLAN_RESTORATION_REMINDER =
"Reminder: restored context is not proof of completion. Verify current state before claiming work is done.";
const MAX_PENDING_SUBAGENT_ESCALATION_CHARACTERS = 8_000;
const INCOMPATIBLE_SUBAGENT_PARENT_HOOKS = [
"resources_discover",
"input",
"context",
"before_agent_start",
"before_provider_request",
"before_provider_headers",
"session_before_compact",
"tool_call",
"tool_result",
"message_end",
] as const;

// ============================================================================
// AgentSession Class
Expand Down Expand Up @@ -532,7 +544,11 @@ export class AgentSession {
activeToolNames: this._initialActiveToolNames,
includeAllExtensionTools: true,
});
this._subagentController?.bindParent(this, (name, message) => this._acceptSubagentEscalation(name, message));
this._subagentController?.bindParent(
this,
() => INCOMPATIBLE_SUBAGENT_PARENT_HOOKS.filter((hook) => this._extensionRunner.hasHandlers(hook)),
(name, message) => this._acceptSubagentEscalation(name, message),
);
}

get modelRuntime(): ModelRuntime {
Expand Down
41 changes: 30 additions & 11 deletions packages/coding-agent/src/core/subagents/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export class SubagentController {
#closed = false;
#resetting = false;
#parent: AgentSession | undefined;
#getIncompatibleParentHooks: (() => readonly string[]) | undefined;
#deliverEscalation: ((name: string, message: string) => Promise<void>) | undefined;
#resetPromise: Promise<void> | undefined;
#resetCleanupPromise: Promise<unknown[]> | undefined;
Expand All @@ -204,16 +205,19 @@ export class SubagentController {

createToolDefinitions(): ToolDefinition[] {
const definitions = [
createSpawnAgentTool((name, task, toolCallId) => {
const parent = this.#getBoundParent();
this.#assertReservable(name);
const origin = captureSpawnOrigin(parent, toolCallId);
const captured = this.#captureSession?.(parent, name, origin);
if (!captured) {
throw new Error("Subagent session capture is unavailable");
}
this.spawn(name, task, captured.createSession, origin, parent.agent.telemetryContext, captured);
}),
createSpawnAgentTool(
(name, task, toolCallId) => {
const parent = this.#getBoundParent();
this.#assertReservable(name);
const origin = captureSpawnOrigin(parent, toolCallId);
const captured = this.#captureSession?.(parent, name, origin);
if (!captured) {
throw new Error("Subagent session capture is unavailable");
}
this.spawn(name, task, captured.createSession, origin, parent.agent.telemetryContext, captured);
},
() => this.#assertParentPolicyCompatible(),
),
createAgentActionTool((request) => {
const parent = this.#getBoundParent();
return request.action === "message"
Expand All @@ -229,12 +233,17 @@ export class SubagentController {
return definitions.map((definition) => definition as unknown as ToolDefinition);
}

bindParent(session: AgentSession, deliverEscalation?: (name: string, message: string) => Promise<void>): void {
bindParent(
session: AgentSession,
getIncompatibleParentHooks: () => readonly string[],
deliverEscalation?: (name: string, message: string) => Promise<void>,
): void {
this.#assertOpen();
if (this.#parent) {
throw new Error("Subagent controller parent is already bound");
}
this.#parent = session;
this.#getIncompatibleParentHooks = getIncompatibleParentHooks;
this.#deliverEscalation = deliverEscalation;
}

Expand Down Expand Up @@ -318,6 +327,7 @@ export class SubagentController {
telemetryContext: TelemetryContext = this.#telemetryContext,
captured?: CapturedSubagentSession,
): void {
this.#assertParentPolicyCompatible();
this.#assertReservable(name);
if (this.#persistRoster && !captured) {
throw new Error("Persistent subagent admission requires captured session state");
Expand Down Expand Up @@ -715,6 +725,7 @@ export class SubagentController {
const resetCleanup = this.#resetCleanupPromise;
const records = this.#detachRecords(closedError);
this.#parent = undefined;
this.#getIncompatibleParentHooks = undefined;
this.#deliverEscalation = undefined;
const shutdownCleanup = this.#cleanupRecords(records);
this.#publish(
Expand Down Expand Up @@ -1542,6 +1553,14 @@ export class SubagentController {
if (this.#resetting) throw this.#resetError();
}

#assertParentPolicyCompatible(): void {
const incompatibleHooks = this.#getIncompatibleParentHooks?.() ?? [];
if (incompatibleHooks.length === 0) return;
throw new Error(
`Cannot spawn a subagent while parent extensions register incompatible hooks: ${incompatibleHooks.join(", ")}. Remove those hooks or disable subagents for this session.`,
);
}

#assertReservable(name: string): void {
this.#assertOpen();
if (!SUBAGENT_NAME_PATTERN.test(name)) {
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/src/core/tools/subagents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ function assertAgentsRequest(request: unknown): asserts request is AgentsRequest

export function createSpawnAgentTool(
spawn: (name: string, task: string, toolCallId: string) => void,
assertCanSpawn: () => void = () => {},
): ToolDefinition<typeof spawnAgentSchema, SpawnAgentDetails> {
return {
name: "spawn_agent",
Expand All @@ -173,6 +174,7 @@ export function createSpawnAgentTool(
parameters: spawnAgentSchema,
executionMode: "parallel",
execute: async (toolCallId, { name, task }) => {
assertCanSpawn();
if (!SUBAGENT_NAME_PATTERN.test(name)) {
throw new Error(`Invalid subagent name: ${name}`);
}
Expand Down
Loading
Loading