From 3522224b25a78ec2cac41ce2e8e1612e93dbac76 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 10 Apr 2026 15:56:03 +0100 Subject: [PATCH] test: trim provider runtime from agents hotspots --- ...subagents.sessions-spawn.lifecycle.test.ts | 30 +++-- ...s.subagents.sessions-spawn.test-harness.ts | 18 ++- ...tra-params.cache-retention-default.test.ts | 15 ++- ...ra-params.openrouter-cache-control.test.ts | 1 + .../extra-params.test-support.ts | 31 +++-- .../model.startup-retry.test.ts | 13 ++ src/agents/subagent-announce.ts | 112 +----------------- src/agents/subagent-registry-lifecycle.ts | 3 +- src/agents/subagent-registry.ts | 5 + src/agents/subagent-spawn.runtime.ts | 2 +- src/agents/subagent-system-prompt.ts | 112 ++++++++++++++++++ src/agents/system-prompt.test.ts | 2 +- src/agents/tools/sessions-spawn-tool.ts | 11 +- src/agents/transcript-policy.policy.test.ts | 25 ++-- 14 files changed, 229 insertions(+), 151 deletions(-) create mode 100644 src/agents/subagent-system-prompt.ts diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts index 25e10f71b00..f0975337901 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts @@ -71,7 +71,7 @@ function buildDiscordCleanupHooks(onDelete: (key: string | undefined) => void) { }; } -const waitFor = async (predicate: () => boolean, timeoutMs = 1_500) => { +const waitFor = async (predicate: () => boolean, timeoutMs = 3_000) => { await vi.waitFor( () => { expect(predicate()).toBe(true); @@ -103,7 +103,7 @@ async function executeSpawnAndExpectAccepted(params: { }); expect(result.details).toMatchObject({ status: "accepted", - runId: "run-1", + runId: expect.any(String), }); return result; } @@ -131,6 +131,13 @@ async function emitLifecycleEndAndFlush(params: { } } +async function waitForRunCleanup(childSessionKey: string) { + await waitFor(() => { + const run = getLatestSubagentRunByChildSessionKey(childSessionKey); + return run?.cleanupCompletedAt != null; + }); +} + describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { beforeEach(() => { resetSessionsSpawnAnnounceFlowOverride(); @@ -147,7 +154,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { }, }, }); - resetSubagentRegistryForTests(); + resetSubagentRegistryForTests({ persist: false }); hookRunnerMocks.runSubagentSpawning.mockClear(); hookRunnerMocks.runSubagentSpawned.mockClear(); hookRunnerMocks.runSubagentEnded.mockClear(); @@ -167,7 +174,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { resetSessionsSpawnAnnounceFlowOverride(); resetSessionsSpawnHookRunnerOverride(); resetSessionsSpawnConfigOverride(); - resetSubagentRegistryForTests(); + resetSubagentRegistryForTests({ persist: false }); }); afterAll(() => { @@ -211,6 +218,10 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { patchCalls.some((call) => call.label === "my-task") && ctx.calls.filter((call) => call.method === "agent").length >= 2, ); + if (!child.sessionKey) { + throw new Error("missing child sessionKey"); + } + await waitForRunCleanup(child.sessionKey); const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); @@ -387,6 +398,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { getLatestSubagentRunByChildSessionKey(childSessionKey)?.outcome?.status === "timeout" ); }, 20_000); + await waitForRunCleanup(childSessionKey); const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); @@ -412,13 +424,11 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { if (!child.runId) { throw new Error("missing child runId"); } - await emitLifecycleEndAndFlush({ - runId: child.runId, - startedAt: 1000, - endedAt: 2000, - }); - + if (!child.sessionKey) { + throw new Error("missing child sessionKey"); + } await waitFor(() => ctx.calls.filter((call) => call.method === "agent").length >= 2); + await waitForRunCleanup(child.sessionKey); const agentCalls = ctx.calls.filter((call) => call.method === "agent"); expect(agentCalls).toHaveLength(2); 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 4db4e80e6bc..ce262ee15f4 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts @@ -26,6 +26,7 @@ type SessionsSpawnGatewayMockOptions = { const hoisted = vi.hoisted(() => { const callGatewayMock = vi.fn(); + let nextRunId = 0; const defaultConfigOverride = { session: { mainKey: "main", @@ -88,7 +89,15 @@ const hoisted = vi.hoisted(() => { defaultRunSubagentAnnounceFlow, runSubagentAnnounceFlowOverride: defaultRunSubagentAnnounceFlow, }; - return { callGatewayMock, defaultConfigOverride, state }; + return { + callGatewayMock, + defaultConfigOverride, + nextRunId: () => { + nextRunId += 1; + return `run-${nextRunId}`; + }, + state, + }; }); let cachedCreateSessionsSpawnTool: CreateSessionsSpawnTool | null = null; @@ -143,6 +152,7 @@ export async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { subagentRegistryTesting.setDepsForTest({ callGateway: (optsUnknown) => hoisted.callGatewayMock(optsUnknown), loadConfig: () => hoisted.state.configOverride, + cleanupBrowserSessionsForLifecycleEnd: async () => {}, captureSubagentCompletionReply: (sessionKey) => hoisted.state.captureSubagentCompletionReplyOverride(sessionKey), runSubagentAnnounceFlow: (params) => hoisted.state.runSubagentAnnounceFlowOverride(params), @@ -161,7 +171,6 @@ export function setupSessionsSpawnGatewayMock(setupOpts: SessionsSpawnGatewayMoc } { const calls: Array = []; const waitCalls: Array = []; - let agentCallCount = 0; let childRunId: string | undefined; let childSessionKey: string | undefined; @@ -182,8 +191,7 @@ export function setupSessionsSpawnGatewayMock(setupOpts: SessionsSpawnGatewayMoc } if (request.method === "agent") { - agentCallCount += 1; - const runId = `run-${agentCallCount}`; + const runId = hoisted.nextRunId(); const params = request.params as { lane?: string; sessionKey?: string } | undefined; // Capture only the subagent run metadata. if (params?.lane === "subagent") { @@ -194,7 +202,7 @@ export function setupSessionsSpawnGatewayMock(setupOpts: SessionsSpawnGatewayMoc return { runId, status: "accepted", - acceptedAt: 1000 + agentCallCount, + acceptedAt: Date.now(), }; } diff --git a/src/agents/pi-embedded-runner/extra-params.cache-retention-default.test.ts b/src/agents/pi-embedded-runner/extra-params.cache-retention-default.test.ts index dffac9241de..610239cca2e 100644 --- a/src/agents/pi-embedded-runner/extra-params.cache-retention-default.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.cache-retention-default.test.ts @@ -1,7 +1,7 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { isOpenRouterAnthropicModelRef } from "./anthropic-family-cache-semantics.js"; -import { applyExtraParamsToAgent } from "./extra-params.js"; +import { __testing as extraParamsTesting, applyExtraParamsToAgent } from "./extra-params.js"; import { resolveCacheRetention } from "./prompt-cache-retention.js"; function applyAndExpectWrapped(params: { @@ -36,6 +36,17 @@ vi.mock("./logger.js", () => ({ }, })); +beforeEach(() => { + extraParamsTesting.setProviderRuntimeDepsForTest({ + prepareProviderExtraParams: () => undefined, + wrapProviderStreamFn: () => undefined, + }); +}); + +afterEach(() => { + extraParamsTesting.resetProviderRuntimeDepsForTest(); +}); + describe("cacheRetention default behavior", () => { it("returns 'short' for Anthropic when not configured", () => { applyAndExpectWrapped({ diff --git a/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts b/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts index 716ee888f4c..25ed60b2bac 100644 --- a/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts @@ -25,6 +25,7 @@ function runOpenRouterPayload(payload: StreamPayload, modelId: string) { provider: "openrouter", id: modelId, } as Model<"openai-completions">, + mockProviderRuntime: true, payload, }); } diff --git a/src/agents/pi-embedded-runner/extra-params.test-support.ts b/src/agents/pi-embedded-runner/extra-params.test-support.ts index 3e1dfd1ffbb..0c1540b9c5c 100644 --- a/src/agents/pi-embedded-runner/extra-params.test-support.ts +++ b/src/agents/pi-embedded-runner/extra-params.test-support.ts @@ -2,7 +2,7 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import type { Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai"; import type { ThinkLevel } from "../../auto-reply/thinking.shared.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { applyExtraParamsToAgent } from "./extra-params.js"; +import { __testing as extraParamsTesting, applyExtraParamsToAgent } from "./extra-params.js"; export type ExtraParamsCapture> = { headers?: Record; @@ -31,6 +31,7 @@ type RunExtraParamsCaseParams< callerHeaders?: Record; cfg?: OpenClawConfig; model: Model; + mockProviderRuntime?: boolean; options?: SimpleStreamOptions; payload: TPayload; thinkingLevel?: ThinkLevel; @@ -52,14 +53,26 @@ export function runExtraParamsCase< }; const agent = { streamFn: baseStreamFn }; - applyExtraParamsToAgent( - agent, - params.cfg, - params.applyProvider ?? params.model.provider, - params.applyModelId ?? params.model.id, - undefined, - params.thinkingLevel, - ); + if (params.mockProviderRuntime === true) { + extraParamsTesting.setProviderRuntimeDepsForTest({ + prepareProviderExtraParams: () => undefined, + wrapProviderStreamFn: () => undefined, + }); + } + try { + applyExtraParamsToAgent( + agent, + params.cfg, + params.applyProvider ?? params.model.provider, + params.applyModelId ?? params.model.id, + undefined, + params.thinkingLevel, + ); + } finally { + if (params.mockProviderRuntime === true) { + extraParamsTesting.resetProviderRuntimeDepsForTest(); + } + } const context: Context = { messages: [] }; void agent.streamFn?.(params.model, context, { diff --git a/src/agents/pi-embedded-runner/model.startup-retry.test.ts b/src/agents/pi-embedded-runner/model.startup-retry.test.ts index f0ff9ae2614..f9a6c51678a 100644 --- a/src/agents/pi-embedded-runner/model.startup-retry.test.ts +++ b/src/agents/pi-embedded-runner/model.startup-retry.test.ts @@ -34,6 +34,19 @@ vi.mock("../pi-model-discovery.js", () => ({ discoverModels: discoverModelsMock, })); +vi.mock("../../plugins/provider-runtime.js", () => ({ + applyProviderResolvedModelCompatWithPlugins: () => undefined, + applyProviderResolvedTransportWithPlugin: () => undefined, + buildProviderUnknownModelHintWithPlugin: () => undefined, + clearProviderRuntimeHookCache: () => {}, + normalizeProviderResolvedModelWithPlugin: () => undefined, + normalizeProviderTransportWithPlugin: () => undefined, + prepareProviderDynamicModel: async () => {}, + resolveProviderBuiltInModelSuppression: () => undefined, + runProviderDynamicModel: () => undefined, + shouldPreferProviderRuntimeResolvedModel: () => false, +})); + describe("resolveModelAsync startup retry", () => { const runtimeHooks = { applyProviderResolvedModelCompatWithPlugins: () => undefined, diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index d1a49bc8f6f..fe068897b73 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -1,5 +1,4 @@ import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; -import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js"; import { defaultRuntime } from "../runtime.js"; import { isCronSessionKey } from "../sessions/session-key-utils.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; @@ -61,116 +60,7 @@ function loadSubagentRegistryRuntime() { return subagentRegistryRuntimePromise; } -export function buildSubagentSystemPrompt(params: { - requesterSessionKey?: string; - requesterOrigin?: DeliveryContext; - childSessionKey: string; - label?: string; - task?: string; - /** Whether ACP-specific routing guidance should be included. Defaults to true. */ - acpEnabled?: boolean; - /** Depth of the child being spawned (1 = sub-agent, 2 = sub-sub-agent). */ - childDepth?: number; - /** Config value: max allowed spawn depth. */ - maxSpawnDepth?: number; -}) { - const taskText = - typeof params.task === "string" && params.task.trim() - ? params.task.replace(/\s+/g, " ").trim() - : "{{TASK_DESCRIPTION}}"; - const childDepth = typeof params.childDepth === "number" ? params.childDepth : 1; - const maxSpawnDepth = - typeof params.maxSpawnDepth === "number" - ? params.maxSpawnDepth - : DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; - const acpEnabled = params.acpEnabled !== false; - const canSpawn = childDepth < maxSpawnDepth; - const parentLabel = childDepth >= 2 ? "parent orchestrator" : "main agent"; - - const lines = [ - "# Subagent Context", - "", - `You are a **subagent** spawned by the ${parentLabel} for a specific task.`, - "", - "## Your Role", - `- You were created to handle: ${taskText}`, - "- Complete this task. That's your entire purpose.", - `- You are NOT the ${parentLabel}. Don't try to be.`, - "", - "## Rules", - "1. **Stay focused** - Do your assigned task, nothing else", - `2. **Complete the task** - Your final message will be automatically reported to the ${parentLabel}`, - "3. **Don't initiate** - No heartbeats, no proactive actions, no side quests", - "4. **Be ephemeral** - You may be terminated after task completion. That's fine.", - "5. **Trust push-based completion** - Descendant results are auto-announced back to you; do not busy-poll for status.", - "6. **Recover from truncated tool output** - If you see a notice like `[... N more characters truncated]`, assume prior output was reduced. Re-read only what you need using smaller chunks (`read` with offset/limit, or targeted `rg`/`head`/`tail`) instead of full-file `cat`.", - "", - "## Output Format", - "When complete, your final response should include:", - `- What you accomplished or found`, - `- Any relevant details the ${parentLabel} should know`, - "- Keep it concise but informative", - "", - "## What You DON'T Do", - `- NO user conversations (that's ${parentLabel}'s job)`, - "- NO external messages (email, tweets, etc.) unless explicitly tasked with a specific recipient/channel", - "- NO cron jobs or persistent state", - `- NO pretending to be the ${parentLabel}`, - `- Only use the \`message\` tool when explicitly instructed to contact a specific external recipient; otherwise return plain text and let the ${parentLabel} deliver it`, - "", - ]; - - if (canSpawn) { - lines.push( - "## Sub-Agent Spawning", - "You CAN spawn your own sub-agents for parallel or complex work using `sessions_spawn`.", - "Use the `subagents` tool to steer, kill, or do an on-demand status check for your spawned sub-agents.", - "Your sub-agents will announce their results back to you automatically (not to the main agent).", - "Default workflow: spawn work, continue orchestrating, and wait for auto-announced completions.", - "Auto-announce is push-based. After spawning children, do NOT call sessions_list, sessions_history, exec sleep, or any polling tool.", - "Wait for completion events to arrive as user messages.", - "Track expected child session keys and only send your final answer after completion events for ALL expected children arrive.", - "If a child completion event arrives AFTER you already sent your final answer, reply ONLY with NO_REPLY.", - "Do NOT repeatedly poll `subagents list` in a loop unless you are actively debugging or intervening.", - "Coordinate their work and synthesize results before reporting back.", - ...(acpEnabled - ? [ - 'For ACP harness sessions (codex/claudecode/gemini), use `sessions_spawn` with `runtime: "acp"` (set `agentId` unless `acp.defaultAgent` is configured).', - '`agents_list` and `subagents` apply to OpenClaw sub-agents (`runtime: "subagent"`); ACP harness ids are controlled by `acp.allowedAgents`.', - "Do not ask users to run slash commands or CLI when `sessions_spawn` can do it directly.", - "Do not use `exec` (`openclaw ...`, `acpx ...`) to spawn ACP sessions.", - 'Use `subagents` only for OpenClaw subagents (`runtime: "subagent"`).', - "Subagent results auto-announce back to you; ACP sessions continue in their bound thread.", - "Avoid polling loops; spawn, orchestrate, and synthesize results.", - ] - : []), - "", - ); - } else if (childDepth >= 2) { - lines.push( - "## Sub-Agent Spawning", - "You are a leaf worker and CANNOT spawn further sub-agents. Focus on your assigned task.", - "", - ); - } - - lines.push( - "## Session Context", - ...[ - params.label ? `- Label: ${params.label}` : undefined, - params.requesterSessionKey - ? `- Requester session: ${params.requesterSessionKey}.` - : undefined, - params.requesterOrigin?.channel - ? `- Requester channel: ${params.requesterOrigin.channel}.` - : undefined, - `- Your session: ${params.childSessionKey}.`, - ].filter((line): line is string => line !== undefined), - "", - ); - return lines.join("\n"); -} - +export { buildSubagentSystemPrompt } from "./subagent-system-prompt.js"; export { captureSubagentCompletionReply } from "./subagent-announce-output.js"; export type { SubagentRunOutcome } from "./subagent-announce-output.js"; diff --git a/src/agents/subagent-registry-lifecycle.ts b/src/agents/subagent-registry-lifecycle.ts index 64d87aaef7d..794db311fb7 100644 --- a/src/agents/subagent-registry-lifecycle.ts +++ b/src/agents/subagent-registry-lifecycle.ts @@ -61,6 +61,7 @@ export function createSubagentRegistryLifecycleController(params: { }): Promise; resumeSubagentRun(runId: string): void; captureSubagentCompletionReply: typeof captureSubagentCompletionReply; + cleanupBrowserSessionsForLifecycleEnd?: typeof cleanupBrowserSessionsForLifecycleEnd; runSubagentAnnounceFlow: typeof runSubagentAnnounceFlow; warn(message: string, meta?: Record): void; }) { @@ -608,7 +609,7 @@ export function createSubagentRegistryLifecycleController(params: { return; } - await cleanupBrowserSessionsForLifecycleEnd({ + await (params.cleanupBrowserSessionsForLifecycleEnd ?? cleanupBrowserSessionsForLifecycleEnd)({ sessionKeys: [entry.childSessionKey], onWarn: (msg) => params.warn(msg, { runId: entry.runId }), }); diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index dd1c168b302..b6337f62773 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -1,3 +1,4 @@ +import { cleanupBrowserSessionsForLifecycleEnd } from "../browser-lifecycle-cleanup.js"; import { loadConfig } from "../config/config.js"; import type { ensureContextEnginesInitialized as ensureContextEnginesInitializedFn } from "../context-engine/init.js"; import type { resolveContextEngine as resolveContextEngineFn } from "../context-engine/registry.js"; @@ -64,6 +65,7 @@ const log = createSubsystemLogger("agents/subagent-registry"); type SubagentRegistryDeps = { callGateway: typeof callGateway; captureSubagentCompletionReply: typeof subagentAnnounceModule.captureSubagentCompletionReply; + cleanupBrowserSessionsForLifecycleEnd: typeof cleanupBrowserSessionsForLifecycleEnd; getSubagentRunsSnapshotForRead: typeof getSubagentRunsSnapshotForRead; loadConfig: typeof loadConfig; onAgentEvent: typeof onAgentEvent; @@ -80,6 +82,7 @@ const defaultSubagentRegistryDeps: SubagentRegistryDeps = { callGateway, captureSubagentCompletionReply: (sessionKey) => subagentAnnounceModule.captureSubagentCompletionReply(sessionKey), + cleanupBrowserSessionsForLifecycleEnd, getSubagentRunsSnapshotForRead, loadConfig, onAgentEvent, @@ -313,6 +316,8 @@ const subagentLifecycleController = createSubagentRegistryLifecycleController({ resumeSubagentRun, captureSubagentCompletionReply: (sessionKey) => subagentRegistryDeps.captureSubagentCompletionReply(sessionKey), + cleanupBrowserSessionsForLifecycleEnd: (args) => + subagentRegistryDeps.cleanupBrowserSessionsForLifecycleEnd(args), runSubagentAnnounceFlow: (params) => subagentRegistryDeps.runSubagentAnnounceFlow(params), warn: (message, meta) => log.warn(message, meta), }); diff --git a/src/agents/subagent-spawn.runtime.ts b/src/agents/subagent-spawn.runtime.ts index b53b6f9d080..d2c8a4aea56 100644 --- a/src/agents/subagent-spawn.runtime.ts +++ b/src/agents/subagent-spawn.runtime.ts @@ -15,7 +15,7 @@ export { resolveAgentConfig } from "./agent-scope.js"; export { AGENT_LANE_SUBAGENT } from "./lanes.js"; export { resolveSubagentSpawnModelSelection } from "./model-selection.js"; export { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js"; -export { buildSubagentSystemPrompt } from "./subagent-announce.js"; +export { buildSubagentSystemPrompt } from "./subagent-system-prompt.js"; export { resolveDisplaySessionKey, resolveInternalSessionKey, diff --git a/src/agents/subagent-system-prompt.ts b/src/agents/subagent-system-prompt.ts new file mode 100644 index 00000000000..0a75a9272be --- /dev/null +++ b/src/agents/subagent-system-prompt.ts @@ -0,0 +1,112 @@ +import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js"; +import type { DeliveryContext } from "../utils/delivery-context.js"; + +export function buildSubagentSystemPrompt(params: { + requesterSessionKey?: string; + requesterOrigin?: DeliveryContext; + childSessionKey: string; + label?: string; + task?: string; + /** Whether ACP-specific routing guidance should be included. Defaults to true. */ + acpEnabled?: boolean; + /** Depth of the child being spawned (1 = sub-agent, 2 = sub-sub-agent). */ + childDepth?: number; + /** Config value: max allowed spawn depth. */ + maxSpawnDepth?: number; +}) { + const taskText = + typeof params.task === "string" && params.task.trim() + ? params.task.replace(/\s+/g, " ").trim() + : "{{TASK_DESCRIPTION}}"; + const childDepth = typeof params.childDepth === "number" ? params.childDepth : 1; + const maxSpawnDepth = + typeof params.maxSpawnDepth === "number" + ? params.maxSpawnDepth + : DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; + const acpEnabled = params.acpEnabled !== false; + const canSpawn = childDepth < maxSpawnDepth; + const parentLabel = childDepth >= 2 ? "parent orchestrator" : "main agent"; + + const lines = [ + "# Subagent Context", + "", + `You are a **subagent** spawned by the ${parentLabel} for a specific task.`, + "", + "## Your Role", + `- You were created to handle: ${taskText}`, + "- Complete this task. That's your entire purpose.", + `- You are NOT the ${parentLabel}. Don't try to be.`, + "", + "## Rules", + "1. **Stay focused** - Do your assigned task, nothing else", + `2. **Complete the task** - Your final message will be automatically reported to the ${parentLabel}`, + "3. **Don't initiate** - No heartbeats, no proactive actions, no side quests", + "4. **Be ephemeral** - You may be terminated after task completion. That's fine.", + "5. **Trust push-based completion** - Descendant results are auto-announced back to you; do not busy-poll for status.", + "6. **Recover from truncated tool output** - If you see a notice like `[... N more characters truncated]`, assume prior output was reduced. Re-read only what you need using smaller chunks (`read` with offset/limit, or targeted `rg`/`head`/`tail`) instead of full-file `cat`.", + "", + "## Output Format", + "When complete, your final response should include:", + "- What you accomplished or found", + `- Any relevant details the ${parentLabel} should know`, + "- Keep it concise but informative", + "", + "## What You DON'T Do", + `- NO user conversations (that's ${parentLabel}'s job)`, + "- NO external messages (email, tweets, etc.) unless explicitly tasked with a specific recipient/channel", + "- NO cron jobs or persistent state", + `- NO pretending to be the ${parentLabel}`, + `- Only use the \`message\` tool when explicitly instructed to contact a specific external recipient; otherwise return plain text and let the ${parentLabel} deliver it`, + "", + ]; + + if (canSpawn) { + lines.push( + "## Sub-Agent Spawning", + "You CAN spawn your own sub-agents for parallel or complex work using `sessions_spawn`.", + "Use the `subagents` tool to steer, kill, or do an on-demand status check for your spawned sub-agents.", + "Your sub-agents will announce their results back to you automatically (not to the main agent).", + "Default workflow: spawn work, continue orchestrating, and wait for auto-announced completions.", + "Auto-announce is push-based. After spawning children, do NOT call sessions_list, sessions_history, exec sleep, or any polling tool.", + "Wait for completion events to arrive as user messages.", + "Track expected child session keys and only send your final answer after completion events for ALL expected children arrive.", + "If a child completion event arrives AFTER you already sent your final answer, reply ONLY with NO_REPLY.", + "Do NOT repeatedly poll `subagents list` in a loop unless you are actively debugging or intervening.", + "Coordinate their work and synthesize results before reporting back.", + ...(acpEnabled + ? [ + 'For ACP harness sessions (codex/claudecode/gemini), use `sessions_spawn` with `runtime: "acp"` (set `agentId` unless `acp.defaultAgent` is configured).', + '`agents_list` and `subagents` apply to OpenClaw sub-agents (`runtime: "subagent"`); ACP harness ids are controlled by `acp.allowedAgents`.', + "Do not ask users to run slash commands or CLI when `sessions_spawn` can do it directly.", + "Do not use `exec` (`openclaw ...`, `acpx ...`) to spawn ACP sessions.", + 'Use `subagents` only for OpenClaw subagents (`runtime: "subagent"`).', + "Subagent results auto-announce back to you; ACP sessions continue in their bound thread.", + "Avoid polling loops; spawn, orchestrate, and synthesize results.", + ] + : []), + "", + ); + } else if (childDepth >= 2) { + lines.push( + "## Sub-Agent Spawning", + "You are a leaf worker and CANNOT spawn further sub-agents. Focus on your assigned task.", + "", + ); + } + + lines.push( + "## Session Context", + ...[ + params.label ? `- Label: ${params.label}` : undefined, + params.requesterSessionKey + ? `- Requester session: ${params.requesterSessionKey}.` + : undefined, + params.requesterOrigin?.channel + ? `- Requester channel: ${params.requesterOrigin.channel}.` + : undefined, + `- Your session: ${params.childSessionKey}.`, + ].filter((line): line is string => line !== undefined), + "", + ); + return lines.join("\n"); +} diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 68e744842ef..38f05f051c1 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { typedCases } from "../test-utils/typed-cases.js"; -import { buildSubagentSystemPrompt } from "./subagent-announce.js"; +import { buildSubagentSystemPrompt } from "./subagent-system-prompt.js"; import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js"; import { buildAgentSystemPrompt, buildRuntimeLine } from "./system-prompt.js"; diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index fdd84cf7c15..709cd3b5977 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -3,7 +3,6 @@ import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; import { normalizeDeliveryContext } from "../../utils/delivery-context.js"; import type { GatewayMessageChannel } from "../../utils/message-channel.js"; -import { isSpawnAcpAcceptedResult, spawnAcpDirect } from "../acp-spawn.js"; import { optionalStringEnum } from "../schema/typebox.js"; import type { SpawnedToolContext } from "../spawned-context.js"; import { registerSubagentRun } from "../subagent-registry.js"; @@ -35,6 +34,15 @@ const UNSUPPORTED_SESSIONS_SPAWN_PARAM_KEYS = [ "reply_to", ] as const; +type AcpSpawnModule = typeof import("../acp-spawn.js"); + +let acpSpawnModulePromise: Promise | undefined; + +async function loadAcpSpawnModule(): Promise { + acpSpawnModulePromise ??= import("../acp-spawn.js"); + return await acpSpawnModulePromise; +} + function summarizeError(err: unknown): string { if (err instanceof Error) { return err.message; @@ -207,6 +215,7 @@ export function createSessionsSpawnTool( } if (runtime === "acp") { + const { isSpawnAcpAcceptedResult, spawnAcpDirect } = await loadAcpSpawnModule(); if (Array.isArray(attachments) && attachments.length > 0) { return jsonResult({ status: "error", diff --git a/src/agents/transcript-policy.policy.test.ts b/src/agents/transcript-policy.policy.test.ts index cfbfbf9198b..208281834ef 100644 --- a/src/agents/transcript-policy.policy.test.ts +++ b/src/agents/transcript-policy.policy.test.ts @@ -1,9 +1,18 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -vi.unmock("../plugins/provider-runtime.js"); -vi.unmock("../plugins/provider-runtime.runtime.js"); -vi.unmock("../plugins/providers.runtime.js"); +vi.mock("../plugins/provider-runtime.js", () => ({ + resolveProviderRuntimePlugin: vi.fn(({ provider }: { provider?: string }) => + provider === "mistral" + ? { + buildReplayPolicy: () => ({ + sanitizeToolCallIds: true, + toolCallIdMode: "strict9", + }), + } + : undefined, + ), +})); let resolveTranscriptPolicy: typeof import("./transcript-policy.js").resolveTranscriptPolicy; const MISTRAL_PLUGIN_CONFIG = { @@ -32,15 +41,11 @@ function createProviderRuntimeSmokeContext(): { }; } -beforeEach(async () => { - vi.resetModules(); - vi.doUnmock("../plugins/provider-runtime.js"); - vi.doUnmock("../plugins/provider-runtime.runtime.js"); - vi.doUnmock("../plugins/providers.runtime.js"); +beforeAll(async () => { ({ resolveTranscriptPolicy } = await import("./transcript-policy.js")); }); -describe("resolveTranscriptPolicy e2e smoke", () => { +describe("resolveTranscriptPolicy provider replay policy", () => { it("uses images-only sanitization without tool-call id rewriting for OpenAI models", () => { const policy = resolveTranscriptPolicy({ ...createProviderRuntimeSmokeContext(),