mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
test: trim provider runtime from agents hotspots
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<GatewayRequest> = [];
|
||||
const waitCalls: Array<AgentWaitCall> = [];
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -25,6 +25,7 @@ function runOpenRouterPayload(payload: StreamPayload, modelId: string) {
|
||||
provider: "openrouter",
|
||||
id: modelId,
|
||||
} as Model<"openai-completions">,
|
||||
mockProviderRuntime: true,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<TPayload extends Record<string, unknown>> = {
|
||||
headers?: Record<string, string>;
|
||||
@@ -31,6 +31,7 @@ type RunExtraParamsCaseParams<
|
||||
callerHeaders?: Record<string, string>;
|
||||
cfg?: OpenClawConfig;
|
||||
model: Model<TApi>;
|
||||
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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ export function createSubagentRegistryLifecycleController(params: {
|
||||
}): Promise<void>;
|
||||
resumeSubagentRun(runId: string): void;
|
||||
captureSubagentCompletionReply: typeof captureSubagentCompletionReply;
|
||||
cleanupBrowserSessionsForLifecycleEnd?: typeof cleanupBrowserSessionsForLifecycleEnd;
|
||||
runSubagentAnnounceFlow: typeof runSubagentAnnounceFlow;
|
||||
warn(message: string, meta?: Record<string, unknown>): 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 }),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
112
src/agents/subagent-system-prompt.ts
Normal file
112
src/agents/subagent-system-prompt.ts
Normal file
@@ -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");
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<AcpSpawnModule> | undefined;
|
||||
|
||||
async function loadAcpSpawnModule(): Promise<AcpSpawnModule> {
|
||||
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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user