feat: add forked subagent context

This commit is contained in:
Peter Steinberger
2026-04-23 21:28:45 +01:00
parent abedf9c1f4
commit f5042adf27
19 changed files with 582 additions and 20 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Agents/subagents: add optional forked context for native `sessions_spawn` runs so agents can let a child inherit the requester transcript when needed, while keeping clean isolated sessions as the default; includes prompt guidance, context-engine hook metadata, docs, and QA coverage.
- Providers/OpenAI: add forward-compatible `gpt-5.5` and `gpt-5.5-pro` support for OpenAI API keys, OpenAI Codex OAuth, and the Codex CLI default model.
### Fixes

View File

@@ -78,13 +78,15 @@ four lifecycle points:
### Subagent lifecycle (optional)
OpenClaw currently calls one subagent lifecycle hook:
OpenClaw calls two optional subagent lifecycle hooks:
- **prepareSubagentSpawn** — prepare shared context state before a child run
starts. The hook receives parent/child session keys, `contextMode`
(`isolated` or `fork`), available transcript ids/files, and optional TTL.
If it returns a rollback handle, OpenClaw calls it when spawn fails after
preparation succeeds.
- **onSubagentEnded** — clean up when a subagent session completes or is swept.
The `prepareSubagentSpawn` hook is part of the interface for future use, but
the runtime does not invoke it yet.
### System prompt addition
The `assemble` method can return a `systemPromptAddition` string. OpenClaw
@@ -191,7 +193,7 @@ Optional members:
| `bootstrap(params)` | Method | Initialize engine state for a session. Called once when the engine first sees a session (e.g., import history). |
| `ingestBatch(params)` | Method | Ingest a completed turn as a batch. Called after a run completes, with all messages from that turn at once. |
| `afterTurn(params)` | Method | Post-run lifecycle work (persist state, trigger background compaction). |
| `prepareSubagentSpawn(params)` | Method | Set up shared state for a child session. |
| `prepareSubagentSpawn(params)` | Method | Set up shared state for a child session before it starts. |
| `onSubagentEnded(params)` | Method | Clean up after a subagent ends. |
| `dispose()` | Method | Release resources. Called during gateway shutdown or plugin reload — not per-session. |

View File

@@ -98,8 +98,9 @@ sub-agents. It supports:
## Spawning sub-agents
`sessions_spawn` creates an isolated session for a background task. It is always
non-blocking -- it returns immediately with a `runId` and `childSessionKey`.
`sessions_spawn` creates an isolated session for a background task by default.
It is always non-blocking -- it returns immediately with a `runId` and
`childSessionKey`.
Key options:
@@ -107,6 +108,8 @@ Key options:
- `model` and `thinking` overrides for the child session.
- `thread: true` to bind the spawn to a chat thread (Discord, Slack, etc.).
- `sandbox: "require"` to enforce sandboxing on the child.
- `context: "fork"` for native sub-agents when the child needs the current
requester transcript; omit it or use `context: "isolated"` for a clean child.
Default leaf sub-agents do not get session tools. When
`maxSpawnDepth >= 2`, depth-1 orchestrator sub-agents additionally receive

View File

@@ -69,9 +69,11 @@ Primary goals:
- Keep the tool surface hard to misuse: sub-agents do **not** get session tools by default.
- Support configurable nesting depth for orchestrator patterns.
Cost note: each sub-agent has its **own** context and token usage. For heavy or repetitive
tasks, set a cheaper model for sub-agents and keep your main agent on a higher-quality model.
You can configure this via `agents.defaults.subagents.model` or per-agent overrides.
Cost note: each sub-agent has its **own** context and token usage by default. For heavy or
repetitive tasks, set a cheaper model for sub-agents and keep your main agent on a
higher-quality model. You can configure this via `agents.defaults.subagents.model` or per-agent
overrides. When a child genuinely needs the requester's current transcript, the agent can request
`context: "fork"` on that one spawn.
## Tool
@@ -98,6 +100,10 @@ Tool params:
- `mode: "session"` requires `thread: true`
- `cleanup?` (`delete|keep`, default `keep`)
- `sandbox?` (`inherit|require`, default `inherit`; `require` rejects spawn unless target child runtime is sandboxed)
- `context?` (`isolated|fork`, default `isolated`; native sub-agents only)
- `isolated` creates a clean child transcript and is the default.
- `fork` branches the requester's current transcript into the child session so the child starts with the same conversation context.
- Use `fork` only when the child needs the current transcript. For scoped work, omit `context`.
- `sessions_spawn` does **not** accept channel-delivery params (`target`, `channel`, `to`, `threadId`, `replyTo`, `transport`). For delivery, use `message`/`sessions_send` from the spawned run.
## Thread-bound sessions

View File

@@ -681,6 +681,32 @@ describe("qa mock openai server", () => {
expect(body).toContain("QA_SUBAGENT_CHILD_FIXED");
});
it("records planned sessions_spawn arguments for forked-context QA assertions", async () => {
const server = await startMockServer();
await expectResponsesText(server, {
stream: true,
tools: [SESSIONS_SPAWN_TOOL],
input: [
makeUserInput(
'Forked subagent context QA check. Use sessions_spawn task="Report the visible code" label=qa-fork-context context=fork mode=run.',
),
],
});
const debugResponse = await fetch(`${server.baseUrl}/debug/last-request`);
expect(debugResponse.status).toBe(200);
expect(await debugResponse.json()).toMatchObject({
plannedToolName: "sessions_spawn",
plannedToolArgs: {
task: "Report the visible code",
label: "qa-fork-context",
context: "fork",
mode: "run",
},
});
});
it("surfaces sessions_spawn tool errors instead of echoing child-task markers", async () => {
const server = await startMockServer();

View File

@@ -99,6 +99,7 @@ type MockOpenAiRequestSnapshot = {
providerVariant: MockOpenAiProviderVariant;
imageInputCount: number;
plannedToolName?: string;
plannedToolArgs?: Record<string, unknown>;
};
// Anthropic /v1/messages request/response shapes the mock actually needs.
@@ -577,6 +578,7 @@ function buildExplicitSessionsSpawnArgs(text: string): Record<string, unknown> |
}
const label = extractQuotedToolArg(text, "label") ?? extractBareToolArg(text, "label");
const mode = extractBareToolArg(text, "mode")?.toLowerCase();
const context = extractBareToolArg(text, "context")?.toLowerCase();
const runTimeoutSecondsRaw = extractBareToolArg(text, "runTimeoutSeconds");
const runTimeoutSeconds =
runTimeoutSecondsRaw && /^\d+$/.test(runTimeoutSecondsRaw)
@@ -587,6 +589,7 @@ function buildExplicitSessionsSpawnArgs(text: string): Record<string, unknown> |
...(label ? { label } : {}),
...(extractBareToolArg(text, "thread")?.toLowerCase() === "true" ? { thread: true } : {}),
...(mode === "session" || mode === "run" ? { mode } : {}),
...(context === "fork" || context === "isolated" ? { context } : {}),
...(runTimeoutSeconds !== undefined ? { runTimeoutSeconds } : {}),
};
}
@@ -745,11 +748,27 @@ function buildAssistantText(
if (/fanout worker beta/i.test(prompt)) {
return "BETA-OK";
}
if (/report the visible code/i.test(prompt) && /FORKED-CONTEXT-ALPHA/i.test(allInputText)) {
return "FORKED-CONTEXT-ALPHA";
}
const fanoutCompleteReply = "subagent-1: ok\nsubagent-2: ok";
if (scenarioState.subagentFanoutPhase === 2 && prompt) {
scenarioState.subagentFanoutPhase = 3;
return fanoutCompleteReply;
}
if (
/forked subagent context qa check/i.test(prompt) &&
/FORKED-CONTEXT-ALPHA/i.test(allInputText)
) {
return [
"Worked",
"- FORKED-CONTEXT-ALPHA",
"Evidence",
"- The forked child recovered the visible code from requester transcript context.",
"Blocked",
"- None.",
].join("\n");
}
if (toolOutput && (/\bdelegate\b/i.test(prompt) || /subagent handoff/i.test(prompt))) {
const compact = toolOutput.replace(/\s+/g, " ").trim() || "no delegated output";
return `Delegated task:\n- Inspect the QA workspace via a bounded subagent.\nResult:\n- ${compact}\nEvidence:\n- The child result was folded back into the main thread exactly once.`;
@@ -802,6 +821,25 @@ function extractPlannedToolName(events: StreamEvent[]) {
return undefined;
}
function extractPlannedToolArgs(events: StreamEvent[]) {
for (const event of events) {
if (event.type !== "response.output_item.done") {
continue;
}
const item = event.item as { type?: unknown; arguments?: unknown };
if (item.type !== "function_call" || typeof item.arguments !== "string") {
continue;
}
try {
const parsed = JSON.parse(item.arguments);
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : undefined;
} catch {
return undefined;
}
}
return undefined;
}
type MockAssistantMessageSpec = {
id: string;
phase?: "commentary" | "final_answer";
@@ -1277,6 +1315,14 @@ async function buildResponsesPayload(
if (canCallSessionsSpawn && explicitSessionsSpawnArgs && !toolOutput) {
return buildToolCallEventsWithArgs("sessions_spawn", explicitSessionsSpawnArgs);
}
if (canCallSessionsSpawn && /forked subagent context qa check/i.test(prompt) && !toolOutput) {
return buildToolCallEventsWithArgs("sessions_spawn", {
task: "Report the visible code from the requester transcript.",
label: "qa-fork-context",
mode: "run",
context: "fork",
});
}
if (/tool continuity check/i.test(prompt) && !toolOutput) {
return buildToolCallEventsWithArgs("read", { path: "QA_KICKOFF_TASK.md" });
}
@@ -1814,6 +1860,7 @@ export async function startQaMockOpenAiServer(params?: { host?: string; port?: n
providerVariant: resolveProviderVariant(resolvedModel),
imageInputCount: countImageInputs(input),
plannedToolName: extractPlannedToolName(events),
plannedToolArgs: extractPlannedToolArgs(events),
};
requests.push(lastRequest);
if (requests.length > 50) {
@@ -1869,6 +1916,7 @@ export async function startQaMockOpenAiServer(params?: { host?: string; port?: n
providerVariant: resolveProviderVariant(normalizedModel),
imageInputCount: countImageInputs(input),
plannedToolName: extractPlannedToolName(events),
plannedToolArgs: extractPlannedToolArgs(events),
};
requests.push(lastRequest);
if (requests.length > 50) {

View File

@@ -0,0 +1,62 @@
# Subagent forked context
```yaml qa-scenario
id: subagent-forked-context
title: Subagent forked context
surface: subagents
coverage:
primary:
- agents.subagents
objective: Verify the agent can choose forked subagent context when the child needs the current transcript.
successCriteria:
- Agent launches a native subagent with context=fork.
- Subagent uses the forked requester transcript to recover the visible code.
- Subagent request remains bounded and does not switch to ACP.
- User-visible output includes the delegated result and the visible code.
docsRefs:
- docs/tools/subagents.md
- docs/concepts/session-tool.md
codeRefs:
- src/agents/tools/sessions-spawn-tool.ts
- src/agents/subagent-spawn.ts
execution:
kind: flow
summary: Ask the agent to delegate work that depends on the current transcript and assert sessions_spawn carries context=fork.
config:
contextNeedle: FORKED-CONTEXT-ALPHA
prompt: "Forked subagent context QA check. The visible code in this current conversation is FORKED-CONTEXT-ALPHA. Delegate to a native subagent to report the visible code from the requester transcript. Do not include the visible code in the child task text; the child must recover it from forked transcript context. Use forked context if the child needs the current transcript; otherwise it will not know the code. A spawn-accepted result is not the answer. Wait for the child completion, then make sure user-visible output includes the visible code."
```
```yaml qa-flow
steps:
- name: forks current transcript context for the child
actions:
- call: reset
- call: runAgentPrompt
args:
- ref: env
- sessionKey: agent:qa:forked-context
message:
expr: config.prompt
timeoutMs:
expr: liveTurnTimeoutMs(env, 90000)
- call: waitForCondition
saveAs: outbound
args:
- lambda:
expr: "state.getSnapshot().messages.filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === 'qa-operator' && String(candidate.text ?? '').includes(config.contextNeedle) && !normalizeLowercaseStringOrEmpty(candidate.text).includes('waiting')).at(-1)"
- expr: liveTurnTimeoutMs(env, 45000)
- expr: "env.providerMode === 'mock-openai' ? 100 : 250"
- assert:
expr: "env.mock || String(outbound.text ?? '').includes(config.contextNeedle)"
message:
expr: "`expected live final answer to include fork-only context code ${config.contextNeedle}, got: ${outbound.text}`"
- set: forkDebugRequests
value:
expr: "env.mock ? [...(await fetchJson(`${env.mock.baseUrl}/debug/requests`))] : []"
- assert:
expr: "!env.mock || forkDebugRequests.some((request) => !request.toolOutput && /forked subagent context qa check/i.test(String(request.allInputText ?? '')) && request.plannedToolName === 'sessions_spawn' && (request.plannedToolArgs?.context === 'fork' || /context\\s*=\\s*fork/i.test(String(request.allInputText ?? ''))))"
message:
expr: "`expected sessions_spawn context=fork during forked context scenario, saw ${JSON.stringify(forkDebugRequests.map((request) => ({ plannedToolName: request.plannedToolName ?? null, plannedToolArgs: request.plannedToolArgs ?? null })))} `"
detailsExpr: outbound.text
```

View File

@@ -198,6 +198,17 @@ export async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) {
callGateway: (optsUnknown) => hoisted.callGatewayMock(optsUnknown),
getGlobalHookRunner: () => hoisted.state.hookRunnerOverride,
loadConfig: () => hoisted.state.configOverride,
resolveContextEngine: async () => ({
info: { id: "test", name: "Test" },
assemble: async ({ messages }) => ({ messages, estimatedTokens: 0 }),
compact: async () => ({ ok: true, compacted: false }),
ingest: async () => ({ ingested: false }),
}),
resolveParentForkMaxTokens: () => 100_000,
forkSessionFromParent: async () => ({
sessionId: "forked-session-id",
sessionFile: "/tmp/forked-session.jsonl",
}),
updateSessionStore: async (_storePath, mutator) => mutator({}),
});
cachedSubagentRegistryTesting.setDepsForTest({

View File

@@ -0,0 +1,134 @@
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
loadSubagentSpawnModuleForTest,
setupAcceptedSubagentGatewayMock,
} from "./subagent-spawn.test-helpers.js";
type SessionStore = Record<string, Record<string, unknown>>;
type GatewayRequest = { method?: string; params?: Record<string, unknown> };
function createPersistentStoreMock(store: SessionStore) {
return vi.fn(async (_storePath: unknown, mutator: unknown) => {
if (typeof mutator !== "function") {
throw new Error("missing session store mutator");
}
return await mutator(store);
});
}
describe("sessions_spawn context modes", () => {
beforeEach(() => {
vi.resetModules();
});
it("forks the requester transcript when context=fork", async () => {
const storePath = "/tmp/subagent-context-session-store.json";
const store: SessionStore = {
main: {
sessionId: "parent-session-id",
sessionFile: "/tmp/parent-session.jsonl",
updatedAt: 1,
totalTokens: 1200,
},
};
const callGatewayMock = vi.fn();
setupAcceptedSubagentGatewayMock(callGatewayMock);
const forkSessionFromParentMock = vi.fn(async () => ({
sessionId: "forked-session-id",
sessionFile: "/tmp/forked-session.jsonl",
}));
const prepareSubagentSpawn = vi.fn(async () => undefined);
const { spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({
callGatewayMock,
updateSessionStoreMock: createPersistentStoreMock(store),
forkSessionFromParentMock,
resolveContextEngineMock: vi.fn(async () => ({ prepareSubagentSpawn })),
sessionStorePath: storePath,
});
const result = await spawnSubagentDirect(
{ task: "inspect the current thread", context: "fork" },
{ agentSessionKey: "main" },
);
expect(result).toMatchObject({ status: "accepted", runId: "run-1" });
expect(forkSessionFromParentMock).toHaveBeenCalledWith({
parentEntry: store.main,
agentId: "main",
sessionsDir: path.dirname(storePath),
});
expect(store[result.childSessionKey ?? ""]).toMatchObject({
sessionId: "forked-session-id",
sessionFile: "/tmp/forked-session.jsonl",
forkedFromParent: true,
});
expect(prepareSubagentSpawn).toHaveBeenCalledWith(
expect.objectContaining({
parentSessionKey: "main",
childSessionKey: result.childSessionKey,
contextMode: "fork",
parentSessionId: "parent-session-id",
childSessionId: "forked-session-id",
childSessionFile: "/tmp/forked-session.jsonl",
}),
);
});
it("keeps the default spawn context isolated", async () => {
const store: SessionStore = {
main: { sessionId: "parent-session-id", updatedAt: 1 },
};
const callGatewayMock = vi.fn();
setupAcceptedSubagentGatewayMock(callGatewayMock);
const forkSessionFromParentMock = vi.fn();
const prepareSubagentSpawn = vi.fn(async () => undefined);
const { spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({
callGatewayMock,
updateSessionStoreMock: createPersistentStoreMock(store),
forkSessionFromParentMock,
resolveContextEngineMock: vi.fn(async () => ({ prepareSubagentSpawn })),
});
const result = await spawnSubagentDirect({ task: "clean worker" }, { agentSessionKey: "main" });
expect(result.status).toBe("accepted");
expect(forkSessionFromParentMock).not.toHaveBeenCalled();
expect(prepareSubagentSpawn).toHaveBeenCalledWith(
expect.objectContaining({
parentSessionKey: "main",
childSessionKey: result.childSessionKey,
contextMode: "isolated",
}),
);
});
it("rolls back context-engine preparation when agent start fails", async () => {
const store: SessionStore = {
main: { sessionId: "parent-session-id", updatedAt: 1 },
};
const rollback = vi.fn(async () => undefined);
const callGatewayMock = vi.fn(async (requestUnknown: unknown) => {
const request = requestUnknown as GatewayRequest;
if (request.method === "agent") {
throw new Error("agent start failed");
}
return { ok: true };
});
const { spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({
callGatewayMock,
updateSessionStoreMock: createPersistentStoreMock(store),
resolveContextEngineMock: vi.fn(async () => ({
prepareSubagentSpawn: vi.fn(async () => ({ rollback })),
})),
});
const result = await spawnSubagentDirect({ task: "clean worker" }, { agentSessionKey: "main" });
expect(result).toMatchObject({ status: "error", error: "agent start failed" });
expect(rollback).toHaveBeenCalledTimes(1);
expect(callGatewayMock.mock.calls.map((call) => (call[0] as GatewayRequest).method)).toContain(
"sessions.delete",
);
});
});

View File

@@ -5,6 +5,11 @@ export {
} from "../config/agent-limits.js";
export { loadConfig } from "../config/config.js";
export { mergeSessionEntry, updateSessionStore } from "../config/sessions.js";
export {
forkSessionFromParent,
resolveParentForkMaxTokens,
} from "../auto-reply/reply/session-fork.js";
export { resolveContextEngine } from "../context-engine/registry.js";
export { callGateway } from "../gateway/call.js";
export { ADMIN_SCOPE, isAdminOnlyMethod } from "../gateway/method-scopes.js";
export {

View File

@@ -114,6 +114,9 @@ export async function loadSubagentSpawnModuleForTest(params: {
callGatewayMock: MockFn;
loadConfig?: () => Record<string, unknown>;
updateSessionStoreMock?: MockFn;
forkSessionFromParentMock?: MockFn;
resolveContextEngineMock?: MockFn;
resolveParentForkMaxTokensMock?: MockFn;
pruneLegacyStoreKeysMock?: MockFn;
registerSubagentRunMock?: MockFn;
emitSessionLifecycleEventMock?: MockFn;
@@ -156,6 +159,9 @@ export async function loadSubagentSpawnModuleForTest(params: {
vi.doMock("./subagent-spawn.runtime.js", () => ({
callGateway: (opts: unknown) => params.callGatewayMock(opts),
buildSubagentSystemPrompt: () => "system-prompt",
forkSessionFromParent:
params.forkSessionFromParentMock ??
(async () => ({ sessionId: "forked-session-id", sessionFile: "/tmp/forked-session.jsonl" })),
getGlobalHookRunner: () => params.hookRunner ?? { hasHooks: () => false },
emitSessionLifecycleEvent: (...args: unknown[]) =>
params.emitSessionLifecycleEventMock?.(...args),
@@ -167,6 +173,8 @@ export async function loadSubagentSpawnModuleForTest(params: {
AGENT_LANE_SUBAGENT: "subagent",
loadConfig: () =>
params.loadConfig?.() ?? createSubagentSpawnTestConfig(params.workspaceDir ?? os.tmpdir()),
resolveContextEngine: params.resolveContextEngineMock ?? (async () => ({})),
resolveParentForkMaxTokens: params.resolveParentForkMaxTokensMock ?? (() => 100_000),
mergeSessionEntry: (
current: Record<string, unknown> | undefined,
next: Record<string, unknown>,

View File

@@ -1,6 +1,9 @@
import crypto from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { SubagentSpawnPreparation } from "../context-engine/types.js";
import type { SubagentLifecycleHookRunner } from "../plugins/hooks.js";
import { isValidAgentId, normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
import {
@@ -41,6 +44,7 @@ import {
buildSubagentSystemPrompt,
callGateway,
emitSessionLifecycleEvent,
forkSessionFromParent,
getGlobalHookRunner,
loadConfig,
mergeSessionEntry,
@@ -48,37 +52,55 @@ import {
normalizeDeliveryContext,
pruneLegacyStoreKeys,
resolveAgentConfig,
resolveContextEngine,
resolveDisplaySessionKey,
resolveGatewaySessionStoreTarget,
resolveInternalSessionKey,
resolveMainSessionAlias,
resolveParentForkMaxTokens,
resolveSandboxRuntimeStatus,
updateSessionStore,
isAdminOnlyMethod,
} from "./subagent-spawn.runtime.js";
import {
SUBAGENT_SPAWN_CONTEXT_MODES,
SUBAGENT_SPAWN_MODES,
SUBAGENT_SPAWN_SANDBOX_MODES,
type SpawnSubagentContextMode,
type SpawnSubagentMode,
type SpawnSubagentSandboxMode,
} from "./subagent-spawn.types.js";
export { SUBAGENT_SPAWN_MODES, SUBAGENT_SPAWN_SANDBOX_MODES } from "./subagent-spawn.types.js";
export type { SpawnSubagentMode, SpawnSubagentSandboxMode } from "./subagent-spawn.types.js";
export {
SUBAGENT_SPAWN_CONTEXT_MODES,
SUBAGENT_SPAWN_MODES,
SUBAGENT_SPAWN_SANDBOX_MODES,
} from "./subagent-spawn.types.js";
export type {
SpawnSubagentContextMode,
SpawnSubagentMode,
SpawnSubagentSandboxMode,
} from "./subagent-spawn.types.js";
export { decodeStrictBase64 };
type SubagentSpawnDeps = {
callGateway: typeof callGateway;
forkSessionFromParent: typeof forkSessionFromParent;
getGlobalHookRunner: () => SubagentLifecycleHookRunner | null;
loadConfig: typeof loadConfig;
resolveContextEngine: typeof resolveContextEngine;
resolveParentForkMaxTokens: typeof resolveParentForkMaxTokens;
updateSessionStore: typeof updateSessionStore;
};
const defaultSubagentSpawnDeps: SubagentSpawnDeps = {
callGateway,
forkSessionFromParent,
getGlobalHookRunner,
loadConfig,
resolveContextEngine,
resolveParentForkMaxTokens,
updateSessionStore,
};
@@ -95,6 +117,7 @@ export type SpawnSubagentParams = {
mode?: SpawnSubagentMode;
cleanup?: "delete" | "keep";
sandbox?: SpawnSubagentSandboxMode;
context?: SpawnSubagentContextMode;
lightContext?: boolean;
expectsCompletionMessage?: boolean;
attachments?: Array<{
@@ -209,6 +232,171 @@ async function persistInitialChildSessionRuntimeModel(params: {
}
}
function resolveStoreEntryByKeys(
store: Record<string, SessionEntry>,
keys: readonly string[],
): SessionEntry | undefined {
for (const key of keys) {
const entry = store[key];
if (entry) {
return entry;
}
}
return undefined;
}
type PreparedSpawnContext =
| { status: "ok"; mode: "isolated"; parentEntry?: SessionEntry; childEntry?: SessionEntry }
| {
status: "ok";
mode: "fork";
parentEntry: SessionEntry;
childEntry?: SessionEntry;
forked: { sessionId: string; sessionFile: string };
}
| { status: "error"; error: string };
async function prepareSubagentSessionContext(params: {
cfg: OpenClawConfig;
contextMode: SpawnSubagentContextMode;
requesterAgentId: string;
targetAgentId: string;
requesterInternalKey: string;
childSessionKey: string;
}): Promise<PreparedSpawnContext> {
if (params.contextMode === "isolated") {
return { status: "ok", mode: "isolated" };
}
const childTarget = resolveGatewaySessionStoreTarget({
cfg: params.cfg,
key: params.childSessionKey,
});
const parentTarget = resolveGatewaySessionStoreTarget({
cfg: params.cfg,
key: params.requesterInternalKey,
});
let parentEntry: SessionEntry | undefined;
let childEntry: SessionEntry | undefined;
const forkMaxTokens = subagentSpawnDeps.resolveParentForkMaxTokens(params.cfg);
const sessionsDir = path.dirname(parentTarget.storePath);
try {
const forked = (await updateSubagentSessionStore(childTarget.storePath, async (store) => {
parentEntry = resolveStoreEntryByKeys(store, parentTarget.storeKeys);
childEntry = resolveStoreEntryByKeys(store, childTarget.storeKeys);
if (params.targetAgentId !== params.requesterAgentId) {
throw new Error(
'context="fork" currently requires the same target agent as the requester; use context="isolated" for cross-agent spawns.',
);
}
if (!parentEntry?.sessionId) {
throw new Error(
'context="fork" requested but the requester session transcript is not available.',
);
}
const parentTokens =
typeof parentEntry.totalTokens === "number" && Number.isFinite(parentEntry.totalTokens)
? parentEntry.totalTokens
: 0;
if (forkMaxTokens > 0 && parentTokens > forkMaxTokens) {
throw new Error(
`context="fork" requested but requester context is too large to fork (${parentTokens}/${forkMaxTokens} tokens). Use context="isolated" or compact first.`,
);
}
const fork = await subagentSpawnDeps.forkSessionFromParent({
parentEntry,
agentId: params.requesterAgentId,
sessionsDir,
});
if (!fork) {
throw new Error(
'context="fork" requested but OpenClaw could not fork the requester transcript.',
);
}
pruneLegacyStoreKeys({
store,
canonicalKey: childTarget.canonicalKey,
candidates: childTarget.storeKeys,
});
store[childTarget.canonicalKey] = mergeSessionEntry(store[childTarget.canonicalKey], {
sessionId: fork.sessionId,
sessionFile: fork.sessionFile,
forkedFromParent: true,
});
childEntry = store[childTarget.canonicalKey];
return fork;
})) as { sessionId: string; sessionFile: string } | null;
if (params.contextMode === "fork") {
if (!parentEntry || !forked) {
return {
status: "error",
error: 'context="fork" requested but OpenClaw could not prepare forked context.',
};
}
return {
status: "ok",
mode: "fork",
parentEntry,
childEntry,
forked,
};
}
return { status: "ok", mode: "isolated", parentEntry, childEntry };
} catch (err) {
return { status: "error", error: summarizeError(err) };
}
}
async function prepareContextEngineSubagentSpawn(params: {
cfg: OpenClawConfig;
context: PreparedSpawnContext & { status: "ok" };
requesterInternalKey: string;
childSessionKey: string;
runTimeoutSeconds: number;
}): Promise<
{ status: "ok"; preparation?: SubagentSpawnPreparation } | { status: "error"; error: string }
> {
try {
const engine = await subagentSpawnDeps.resolveContextEngine(params.cfg);
const preparation = await engine.prepareSubagentSpawn?.({
parentSessionKey: params.requesterInternalKey,
childSessionKey: params.childSessionKey,
contextMode: params.context.mode,
parentSessionId: params.context.parentEntry?.sessionId,
parentSessionFile: params.context.parentEntry?.sessionFile,
childSessionId:
params.context.mode === "fork"
? params.context.forked.sessionId
: params.context.childEntry?.sessionId,
childSessionFile:
params.context.mode === "fork"
? params.context.forked.sessionFile
: params.context.childEntry?.sessionFile,
ttlMs: params.runTimeoutSeconds > 0 ? params.runTimeoutSeconds * 1000 : undefined,
});
return { status: "ok", preparation };
} catch (err) {
return {
status: "error",
error: `Context engine subagent preparation failed: ${summarizeError(err)}`,
};
}
}
async function rollbackPreparedContextEngine(
preparation?: SubagentSpawnPreparation,
): Promise<void> {
try {
await preparation?.rollback();
} catch {
// Best-effort cleanup only.
}
}
function sanitizeMountPathHint(value?: string): string | undefined {
const trimmed = normalizeOptionalString(value);
if (!trimmed) {
@@ -382,6 +570,7 @@ export async function spawnSubagentDirect(
const thinkingOverrideRaw = params.thinking;
const requestThreadBinding = params.thread === true;
const sandboxMode = params.sandbox === "require" ? "require" : "inherit";
const contextMode: SpawnSubagentContextMode = params.context === "fork" ? "fork" : "isolated";
const spawnMode = resolveSpawnMode({
requestedMode: params.mode,
threadRequested: requestThreadBinding,
@@ -566,6 +755,25 @@ export async function spawnSubagentDirect(
childSessionKey,
};
}
const preparedSpawnContext = await prepareSubagentSessionContext({
cfg,
contextMode,
requesterAgentId,
targetAgentId,
requesterInternalKey,
childSessionKey,
});
if (preparedSpawnContext.status === "error") {
await cleanupProvisionalSession(childSessionKey, {
emitLifecycleHooks: false,
deleteTranscript: true,
});
return {
status: "error",
error: preparedSpawnContext.error,
childSessionKey,
};
}
if (resolvedModel) {
const runtimeModelPersistError = await persistInitialChildSessionRuntimeModel({
cfg,
@@ -723,6 +931,27 @@ export async function spawnSubagentDirect(
childSessionKey,
};
}
const contextEnginePrepareResult = await prepareContextEngineSubagentSpawn({
cfg,
context: preparedSpawnContext,
requesterInternalKey,
childSessionKey,
runTimeoutSeconds,
});
if (contextEnginePrepareResult.status === "error") {
await cleanupFailedSpawnBeforeAgentStart({
childSessionKey,
attachmentAbsDir,
emitLifecycleHooks: threadBindingReady,
deleteTranscript: true,
});
return {
status: "error",
error: contextEnginePrepareResult.error,
childSessionKey,
};
}
const contextEnginePreparation = contextEnginePrepareResult.preparation;
const childIdem = crypto.randomUUID();
let childRunId: string = childIdem;
@@ -770,6 +999,7 @@ export async function spawnSubagentDirect(
childRunId = runId;
}
} catch (err) {
await rollbackPreparedContextEngine(contextEnginePreparation);
if (attachmentAbsDir) {
try {
await fs.rm(attachmentAbsDir, { recursive: true, force: true });
@@ -852,6 +1082,7 @@ export async function spawnSubagentDirect(
retainAttachmentsOnKeep: retainOnSessionKeep,
});
} catch (err) {
await rollbackPreparedContextEngine(contextEnginePreparation);
if (attachmentAbsDir) {
try {
await fs.rm(attachmentAbsDir, { recursive: true, force: true });

View File

@@ -3,3 +3,6 @@ export type SpawnSubagentMode = (typeof SUBAGENT_SPAWN_MODES)[number];
export const SUBAGENT_SPAWN_SANDBOX_MODES = ["inherit", "require"] as const;
export type SpawnSubagentSandboxMode = (typeof SUBAGENT_SPAWN_SANDBOX_MODES)[number];
export const SUBAGENT_SPAWN_CONTEXT_MODES = ["isolated", "fork"] as const;
export type SpawnSubagentContextMode = (typeof SUBAGENT_SPAWN_CONTEXT_MODES)[number];

View File

@@ -660,12 +660,12 @@ describe("buildAgentSystemPrompt", () => {
expect(messagingPrompt).not.toContain("subagents(action=list|steer|kill)");
expect(spawnOnlyPrompt).toContain(
"- Sub-agent orchestration → use `sessions_spawn(...)` to start delegated work.",
'- Sub-agent orchestration → use `sessions_spawn(...)` to start delegated work; omit `context` for isolated children, set `context:"fork"` only when the child needs the current transcript.',
);
expect(spawnOnlyPrompt).not.toContain("manage already-spawned children");
expect(orchestrationPrompt).toContain(
"- Sub-agent orchestration → use `sessions_spawn(...)` to start delegated work; use `subagents(action=list|steer|kill)` to manage already-spawned children.",
'- Sub-agent orchestration → use `sessions_spawn(...)` to start delegated work; omit `context` for isolated children, set `context:"fork"` only when the child needs the current transcript; use `subagents(action=list|steer|kill)` to manage already-spawned children.',
);
});

View File

@@ -347,8 +347,8 @@ function buildMessagingSection(params: {
const hasSubagents = params.availableTools.has("subagents");
const subagentOrchestrationGuidance = hasSessionsSpawn
? hasSubagents
? "- Sub-agent orchestration → use `sessions_spawn(...)` to start delegated work; use `subagents(action=list|steer|kill)` to manage already-spawned children."
: "- Sub-agent orchestration → use `sessions_spawn(...)` to start delegated work."
? '- Sub-agent orchestration → use `sessions_spawn(...)` to start delegated work; omit `context` for isolated children, set `context:"fork"` only when the child needs the current transcript; use `subagents(action=list|steer|kill)` to manage already-spawned children.'
: '- Sub-agent orchestration → use `sessions_spawn(...)` to start delegated work; omit `context` for isolated children, set `context:"fork"` only when the child needs the current transcript.'
: hasSubagents
? "- Sub-agent orchestration → use `subagents(action=list|steer|kill)` to manage already-spawned children."
: "";
@@ -501,8 +501,8 @@ export function buildAgentSystemPrompt(params: {
sessions_history: "Fetch history for another session/sub-agent",
sessions_send: "Send a message to another session/sub-agent",
sessions_spawn: acpSpawnRuntimeEnabled
? 'Spawn an isolated sub-agent or ACP coding session (runtime="acp" requires `agentId` unless `acp.defaultAgent` is configured; ACP harness ids follow acp.allowedAgents, not agents_list)'
: "Spawn an isolated sub-agent session",
? 'Spawn a sub-agent or ACP coding session; defaults to isolated, native subagents may use context="fork" when current transcript context is required (runtime="acp" requires `agentId` unless `acp.defaultAgent` is configured; ACP harness ids follow acp.allowedAgents, not agents_list)'
: 'Spawn an isolated sub-agent session; use context="fork" only when current transcript context is required',
subagents: "List, steer, or kill sub-agent runs for this requester session",
session_status:
"Show a /status-equivalent status card (usage + time + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
@@ -702,6 +702,7 @@ export function buildAgentSystemPrompt(params: {
"TOOLS.md does not control tool availability; it is user guidance for how to use external tools.",
`For long waits, avoid rapid poll loops: use ${execToolName} with enough yieldMs or ${processToolName}(action=poll, timeout=<ms>).`,
"If a task is more complex or takes longer, spawn a sub-agent. Completion is push-based: it will auto-announce when done.",
'Sub-agents start isolated by default. Use `sessions_spawn` with `context:"fork"` only when the child needs the current transcript context; otherwise omit `context` or use `context:"isolated"`.',
...(acpHarnessSpawnAllowed
? [
'For requests like "do this in codex/claude code/cursor/gemini" or similar ACP harnesses, treat it as ACP harness intent and call `sessions_spawn` with `runtime: "acp"`.',

View File

@@ -33,9 +33,10 @@ export function describeSessionsSendTool(): string {
export function describeSessionsSpawnTool(): string {
return [
'Spawn an isolated session with `runtime="subagent"` or `runtime="acp"`.',
'Spawn a clean isolated session by default with `runtime="subagent"` or `runtime="acp"`.',
'`mode="run"` is one-shot and `mode="session"` is persistent or thread-bound.',
"Subagents inherit the parent workspace directory automatically.",
'For native subagents only, set `context="fork"` when the child needs the current transcript context; otherwise omit it or use `context="isolated"`.',
"Use this when the work should happen in a fresh child session instead of the current one.",
].join(" ");
}

View File

@@ -12,6 +12,7 @@ const hoisted = vi.hoisted(() => {
});
vi.mock("../subagent-spawn.js", () => ({
SUBAGENT_SPAWN_CONTEXT_MODES: ["isolated", "fork"],
SUBAGENT_SPAWN_MODES: ["run", "session"],
spawnSubagentDirect: (...args: unknown[]) => hoisted.spawnSubagentDirectMock(...args),
}));

View File

@@ -6,7 +6,11 @@ import type { GatewayMessageChannel } from "../../utils/message-channel.js";
import { optionalStringEnum } from "../schema/typebox.js";
import type { SpawnedToolContext } from "../spawned-context.js";
import { registerSubagentRun } from "../subagent-registry.js";
import { SUBAGENT_SPAWN_MODES, spawnSubagentDirect } from "../subagent-spawn.js";
import {
SUBAGENT_SPAWN_CONTEXT_MODES,
SUBAGENT_SPAWN_MODES,
spawnSubagentDirect,
} from "../subagent-spawn.js";
import {
describeSessionsSpawnTool,
SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY,
@@ -114,6 +118,10 @@ const SessionsSpawnToolSchema = Type.Object({
mode: optionalStringEnum(SUBAGENT_SPAWN_MODES),
cleanup: optionalStringEnum(["delete", "keep"] as const),
sandbox: optionalStringEnum(SESSIONS_SPAWN_SANDBOX_MODES),
context: optionalStringEnum(SUBAGENT_SPAWN_CONTEXT_MODES, {
description:
'Native subagent context mode. Omit or use "isolated" for a clean child session; use "fork" only when the child needs the requester transcript context.',
}),
streamTo: optionalStringEnum(SESSIONS_SPAWN_ACP_STREAM_TARGETS),
lightContext: Type.Optional(
Type.Boolean({
@@ -185,11 +193,16 @@ export function createSessionsSpawnTool(
params.cleanup === "keep" || params.cleanup === "delete" ? params.cleanup : "keep";
const expectsCompletionMessage = params.expectsCompletionMessage !== false;
const sandbox = params.sandbox === "require" ? "require" : "inherit";
const context =
params.context === "fork" || params.context === "isolated" ? params.context : undefined;
const streamTo = params.streamTo === "parent" ? "parent" : undefined;
const lightContext = params.lightContext === true;
if (runtime === "acp" && lightContext) {
throw new Error("lightContext is only supported for runtime='subagent'.");
}
if (runtime === "acp" && context === "fork") {
throw new Error('context="fork" is only supported for runtime="subagent".');
}
// Back-compat: older callers used timeoutSeconds for this tool.
const timeoutSecondsCandidate =
typeof params.runTimeoutSeconds === "number"
@@ -339,6 +352,7 @@ export function createSessionsSpawnTool(
mode,
cleanup,
sandbox,
context,
lightContext,
expectsCompletionMessage,
attachments,

View File

@@ -282,6 +282,11 @@ export interface ContextEngine {
prepareSubagentSpawn?(params: {
parentSessionKey: string;
childSessionKey: string;
contextMode?: "isolated" | "fork";
parentSessionId?: string;
parentSessionFile?: string;
childSessionId?: string;
childSessionFile?: string;
ttlMs?: number;
}): Promise<SubagentSpawnPreparation | undefined>;