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
2 changes: 1 addition & 1 deletion packages/cli/src/extensions/factories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const CORE_EXTENSIONS_HEAD: ExtensionFactory[] = [
providerRequestLogExtension,
fallbackModelsExtension,
triggersExtension, // Must be before remoteExtension (shutdown ordering)
subagentExtension, // Must be before remoteExtension (shutdown ordering: aborts mirrors first)
remoteExtension,
tunnelToolsExtension,
serviceMessageBridgeExtension,
Expand All @@ -75,7 +76,6 @@ const CORE_EXTENSIONS_TAIL: ExtensionFactory[] = [
updateTodoExtension,
memoryExtension,
spawnSessionExtension,
subagentExtension,
workflowExtension,
planModeToggleExtension,
sandboxEventsExtension,
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/extensions/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ export function buildPizzaPiExtensionFactories(options: BuildExtensionFactoriesO
// session_complete is fired from remoteExtension's shutdown handler (before disconnect).
factories.push(named(triggersExtension, "triggers"));

// Subagent must register BEFORE remote: on session_shutdown, pi emits in
// registration order, so subagent aborts its in-flight relay mirrors and
// ends those child sessions before remote disconnects the worker socket.
// (Unconditional — the subagent tool works without a relay; only mirroring
// is skipped.)
factories.push(named(subagentExtension, "subagent"));

if (!options.skipRelay) {
factories.push(named(remoteExtension, "relay"));
factories.push(named(tunnelToolsExtension, "tunnel-tools"));
Expand Down Expand Up @@ -142,7 +149,6 @@ export function buildPizzaPiExtensionFactories(options: BuildExtensionFactoriesO
named(updateTodoExtension, "todo"),
named(memoryExtension, "memory"),
named(spawnSessionExtension, "spawn-session"),
named(subagentExtension, "subagent"),
named(workflowExtension, "workflow"),
named(planModeToggleExtension, "plan-mode"),
named(sandboxEventsExtension, "sandbox"),
Expand Down
48 changes: 48 additions & 0 deletions packages/cli/src/extensions/spawn-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,51 @@ describe("list_models tool — Ollama Cloud merge", () => {
expect(ids).toContain("anthropic/claude-x");
});
});

// ── spawn_session tool — autoClose plumbing ────────────────────────────────
// Completed children must self-terminate (auto-close) instead of idling on
// the runner forever. Default is true; explicit false opts out.

describe("spawn_session tool — autoClose", () => {
const saved = {
RELAY: process.env.PIZZAPI_RELAY_URL,
KEY: process.env.PIZZAPI_API_KEY,
SESSION: process.env.PIZZAPI_SESSION_ID,
fetch: globalThis.fetch,
};

afterEach(() => {
if (saved.RELAY !== undefined) process.env.PIZZAPI_RELAY_URL = saved.RELAY; else delete process.env.PIZZAPI_RELAY_URL;
if (saved.KEY !== undefined) process.env.PIZZAPI_API_KEY = saved.KEY; else delete process.env.PIZZAPI_API_KEY;
if (saved.SESSION !== undefined) process.env.PIZZAPI_SESSION_ID = saved.SESSION; else delete process.env.PIZZAPI_SESSION_ID;
globalThis.fetch = saved.fetch;
});

function setup() {
process.env.PIZZAPI_RELAY_URL = "http://relay.test";
process.env.PIZZAPI_API_KEY = "test-key";
process.env.PIZZAPI_SESSION_ID = "parent-session";

const bodies: any[] = [];
globalThis.fetch = (async (_url: any, init: any) => {
bodies.push(JSON.parse(init.body));
return new Response(JSON.stringify({ ok: true, sessionId: "child-1" }), { status: 200 });
}) as any;

const pi = createMockPi();
spawnSessionExtension(pi as any);
return { tool: pi.tools.get("spawn_session"), bodies };
}

test("defaults autoClose to true in the spawn request body", async () => {
const { tool, bodies } = setup();
await tool.execute("call-1", { prompt: "do things", runnerId: "r1" });
expect(bodies[0].autoClose).toBe(true);
});

test("passes autoClose: false through when explicitly disabled", async () => {
const { tool, bodies } = setup();
await tool.execute("call-1", { prompt: "stay up", runnerId: "r1", autoClose: false });
expect(bodies[0].autoClose).toBe(false);
});
});
13 changes: 13 additions & 0 deletions packages/cli/src/extensions/spawn-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ export const spawnSessionExtension: ExtensionFactory = (pi) => {
description:
"Runner ID to spawn on. Usually not needed — defaults to the current runner.",
},
autoClose: {
type: "boolean",
description:
"Shut the child session down when it completes its work (default true). " +
"Set false to keep it alive for interactive follow-ups after completion.",
default: true,
},
},
required: ["prompt"],
} as any,
Expand All @@ -115,6 +122,7 @@ export const spawnSessionExtension: ExtensionFactory = (pi) => {
model?: { provider: string; id: string };
cwd?: string;
runnerId?: string;
autoClose?: boolean;
};

const ok = (text: string, details?: Record<string, unknown>) => ({
Expand Down Expand Up @@ -165,6 +173,11 @@ export const spawnSessionExtension: ExtensionFactory = (pi) => {
}
body.parentSessionId = ownSessionId;

// Default to auto-close so completed children don't idle forever on the
// runner. Auto-close itself is guarded: it won't fire if new messages,
// subscriptions, or linked children arrive during the idle re-check.
body.autoClose = params.autoClose !== false;

if (params.model) {
// Note: hidden-model enforcement is done server-side (runners.ts).
// A client-side check here using PIZZAPI_HIDDEN_MODELS would be
Expand Down
1 change: 1 addition & 0 deletions packages/docs/src/content/docs/features/multi-agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Spawn a new independent agent session on the runner. The new session runs in par
| `model` | `{ provider, id }` | | Runner default | Model to use. Call `list_models` to discover available values. |
| `cwd` | `string` | | Parent's cwd | Working directory for the new session. |
| `runnerId` | `string` | | Current runner | Target runner ID. Usually not needed. |
| `autoClose` | `boolean` | | `true` | Shut the child down when it completes its work (guarded — it stays up if new messages or subscriptions arrive). Set `false` to keep it alive for interactive follow-ups after completion. |

### Return value

Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/routes/runners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export const handleRunnersRoute: RouteHandler = async (req, url) => {
: undefined;

const requestedParentSessionId = typeof body.parentSessionId === "string" ? body.parentSessionId : undefined;
const requestedAutoClose = body.autoClose === true;

if (!requestedRunnerId) {
return Response.json({ error: "Missing runnerId" }, { status: 400 });
Expand Down Expand Up @@ -195,6 +196,7 @@ export const handleRunnersRoute: RouteHandler = async (req, url) => {
...(hiddenModels.length > 0 ? { hiddenModels } : {}),
...(requestedAgent ? { agent: requestedAgent } : {}),
...(validatedParentSessionId ? { parentSessionId: validatedParentSessionId } : {}),
...(requestedAutoClose ? { autoClose: true } : {}),
...(requestedResumePath ? { resumePath: requestedResumePath } : {}),
...(requestedResumeId && !requestedResumePath ? { resumeId: requestedResumeId } : {}),
});
Expand Down
Loading