From f5042adf279cdefac72fdd862f0e3306532a6335 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Apr 2026 21:28:45 +0100 Subject: [PATCH] feat: add forked subagent context --- CHANGELOG.md | 1 + docs/concepts/context-engine.md | 12 +- docs/concepts/session-tool.md | 7 +- docs/tools/subagents.md | 12 +- .../src/providers/mock-openai/server.test.ts | 26 ++ .../src/providers/mock-openai/server.ts | 48 ++++ .../agents/subagent-forked-context.md | 62 +++++ ...s.subagents.sessions-spawn.test-harness.ts | 11 + src/agents/subagent-spawn.context.test.ts | 134 ++++++++++ src/agents/subagent-spawn.runtime.ts | 5 + src/agents/subagent-spawn.test-helpers.ts | 8 + src/agents/subagent-spawn.ts | 235 +++++++++++++++++- src/agents/subagent-spawn.types.ts | 3 + src/agents/system-prompt.test.ts | 4 +- src/agents/system-prompt.ts | 9 +- src/agents/tool-description-presets.ts | 3 +- src/agents/tools/sessions-spawn-tool.test.ts | 1 + src/agents/tools/sessions-spawn-tool.ts | 16 +- src/context-engine/types.ts | 5 + 19 files changed, 582 insertions(+), 20 deletions(-) create mode 100644 qa/scenarios/agents/subagent-forked-context.md create mode 100644 src/agents/subagent-spawn.context.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3410c39fae0..df50c7681f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/concepts/context-engine.md b/docs/concepts/context-engine.md index 893e48cd757..5c6a885727c 100644 --- a/docs/concepts/context-engine.md +++ b/docs/concepts/context-engine.md @@ -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. | diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index 6425609d6ba..551e1b270f5 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -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 diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 5ec8ecfb7a7..3d74d951c63 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -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 diff --git a/extensions/qa-lab/src/providers/mock-openai/server.test.ts b/extensions/qa-lab/src/providers/mock-openai/server.test.ts index f95ef6cb5fe..af903846cc6 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.test.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.test.ts @@ -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(); diff --git a/extensions/qa-lab/src/providers/mock-openai/server.ts b/extensions/qa-lab/src/providers/mock-openai/server.ts index d81d27792fb..999f6dd829f 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.ts @@ -99,6 +99,7 @@ type MockOpenAiRequestSnapshot = { providerVariant: MockOpenAiProviderVariant; imageInputCount: number; plannedToolName?: string; + plannedToolArgs?: Record; }; // Anthropic /v1/messages request/response shapes the mock actually needs. @@ -577,6 +578,7 @@ function buildExplicitSessionsSpawnArgs(text: string): Record | } 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 | ...(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) : 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) { diff --git a/qa/scenarios/agents/subagent-forked-context.md b/qa/scenarios/agents/subagent-forked-context.md new file mode 100644 index 00000000000..fef30bba9ed --- /dev/null +++ b/qa/scenarios/agents/subagent-forked-context.md @@ -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 +``` diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts index b9f68afcbc8..e0dcda5163a 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts @@ -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({ diff --git a/src/agents/subagent-spawn.context.test.ts b/src/agents/subagent-spawn.context.test.ts new file mode 100644 index 00000000000..95d65f16e1f --- /dev/null +++ b/src/agents/subagent-spawn.context.test.ts @@ -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>; +type GatewayRequest = { method?: string; params?: Record }; + +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", + ); + }); +}); diff --git a/src/agents/subagent-spawn.runtime.ts b/src/agents/subagent-spawn.runtime.ts index ccd382cbeba..15e9fa48c65 100644 --- a/src/agents/subagent-spawn.runtime.ts +++ b/src/agents/subagent-spawn.runtime.ts @@ -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 { diff --git a/src/agents/subagent-spawn.test-helpers.ts b/src/agents/subagent-spawn.test-helpers.ts index 670fb4bfee2..eadd9933c8a 100644 --- a/src/agents/subagent-spawn.test-helpers.ts +++ b/src/agents/subagent-spawn.test-helpers.ts @@ -114,6 +114,9 @@ export async function loadSubagentSpawnModuleForTest(params: { callGatewayMock: MockFn; loadConfig?: () => Record; 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 | undefined, next: Record, diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 0614397fae2..ce3773aec79 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -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, + 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 { + 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 { + 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 }); diff --git a/src/agents/subagent-spawn.types.ts b/src/agents/subagent-spawn.types.ts index bf1bd7f6586..215102cebfa 100644 --- a/src/agents/subagent-spawn.types.ts +++ b/src/agents/subagent-spawn.types.ts @@ -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]; diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 3c21a1cdb90..a192ef83d27 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -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.', ); }); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 12cd4a70bd7..65937223c8a 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -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=).`, "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"`.', diff --git a/src/agents/tool-description-presets.ts b/src/agents/tool-description-presets.ts index 60f4a27c0e5..0cf455f4b61 100644 --- a/src/agents/tool-description-presets.ts +++ b/src/agents/tool-description-presets.ts @@ -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(" "); } diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index d3c59bc68b1..d570f02080b 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -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), })); diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 66d846674ad..04fe8d605f7 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -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, diff --git a/src/context-engine/types.ts b/src/context-engine/types.ts index 2eea7ea7ba2..21cea5aee37 100644 --- a/src/context-engine/types.ts +++ b/src/context-engine/types.ts @@ -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;