mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
feat: add forked subagent context
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
62
qa/scenarios/agents/subagent-forked-context.md
Normal file
62
qa/scenarios/agents/subagent-forked-context.md
Normal 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
|
||||
```
|
||||
@@ -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({
|
||||
|
||||
134
src/agents/subagent-spawn.context.test.ts
Normal file
134
src/agents/subagent-spawn.context.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"`.',
|
||||
|
||||
@@ -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(" ");
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user