From 31160dc069b7cc5d833b39c53736a41ad3befda2 Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Tue, 21 Apr 2026 17:25:25 +0530 Subject: [PATCH] fix(agents): enforce subagent envelope inheritance on ACP child sessions [AI-assisted] (#69383) * fix: address issue * fix: address review feedback * fix: finalize issue changes * fix: address PR review feedback * address build faiure * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback --- src/agents/acp-spawn.test.ts | 344 ++++++++++++++++++ src/agents/acp-spawn.ts | 168 +++++++++ .../effective-tool-policy.ts | 18 +- ...tools.create-openclaw-coding-tools.test.ts | 177 +++++++++ src/agents/pi-tools.policy.ts | 14 +- src/agents/pi-tools.ts | 18 +- src/agents/subagent-capabilities.ts | 166 ++++++++- src/agents/subagent-spawn.runtime.ts | 5 +- src/agents/subagent-spawn.test-helpers.ts | 1 + src/agents/subagent-spawn.ts | 4 +- src/cli/plugins-cli.list.test.ts | 10 +- src/cli/plugins-cli.ts | 19 +- src/config/agent-limits.ts | 1 + src/gateway/tool-resolution.ts | 19 +- .../runtime/metadata-registry-loader.test.ts | 23 ++ .../runtime/metadata-registry-loader.ts | 2 + src/plugins/status.test.ts | 25 ++ src/plugins/status.ts | 13 +- 18 files changed, 997 insertions(+), 30 deletions(-) diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index ae36d10f2ed..0694b3a7f4a 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -23,6 +23,14 @@ function createDefaultSpawnConfig(): OpenClawConfig { backend: "acpx", allowedAgents: ["codex"], }, + agents: { + defaults: { + subagents: { + allowAgents: ["codex"], + maxSpawnDepth: 2, + }, + }, + }, session: { mainKey: "main", scope: "per-sender", @@ -61,6 +69,9 @@ const hoisted = vi.hoisted(() => { }); const cleanupFailedAcpSpawnMock = vi.fn(); const createRunningTaskRunMock = vi.fn(); + const countActiveRunsForSessionMock = vi.fn(); + const getSubagentRunByChildSessionKeyMock = vi.fn(); + const listTasksForOwnerKeyMock = vi.fn(); const state = { cfg: createDefaultSpawnConfig(), }; @@ -84,6 +95,9 @@ const hoisted = vi.hoisted(() => { normalizeChannelIdMock, cleanupFailedAcpSpawnMock, createRunningTaskRunMock, + countActiveRunsForSessionMock, + getSubagentRunByChildSessionKeyMock, + listTasksForOwnerKeyMock, state, }; }); @@ -110,6 +124,11 @@ vi.mock("../config/sessions/store.js", () => ({ loadSessionStore: hoisted.loadSessionStoreMock, })); +vi.mock("../config/sessions.js", () => ({ + loadSessionStore: hoisted.loadSessionStoreMock, + resolveStorePath: hoisted.resolveStorePathMock, +})); + vi.mock("../config/sessions/transcript.js", () => ({ resolveSessionTranscriptFile: hoisted.resolveSessionTranscriptFileMock, })); @@ -131,6 +150,15 @@ vi.mock("./acp-spawn-parent-stream.js", () => ({ startAcpSpawnParentStreamRelay: hoisted.startAcpSpawnParentStreamRelayMock, })); +vi.mock("./subagent-registry.js", () => ({ + countActiveRunsForSession: hoisted.countActiveRunsForSessionMock, + getSubagentRunByChildSessionKey: hoisted.getSubagentRunByChildSessionKeyMock, +})); + +vi.mock("../tasks/runtime-internal.js", () => ({ + listTasksForOwnerKey: hoisted.listTasksForOwnerKeyMock, +})); + const { isSpawnAcpAcceptedResult, spawnAcpDirect } = await import("./acp-spawn.js"); type SpawnRequest = Parameters[0]; type SpawnContext = Parameters[1]; @@ -490,6 +518,9 @@ describe("spawnAcpDirect", () => { hoisted.getLoadedChannelPluginMock.mockReset().mockReturnValue(undefined); hoisted.cleanupFailedAcpSpawnMock.mockReset().mockResolvedValue(undefined); hoisted.createRunningTaskRunMock.mockReset().mockReturnValue(undefined); + hoisted.countActiveRunsForSessionMock.mockReset().mockReturnValue(0); + hoisted.getSubagentRunByChildSessionKeyMock.mockReset().mockReturnValue(null); + hoisted.listTasksForOwnerKeyMock.mockReset().mockReturnValue([]); hoisted.callGatewayMock.mockReset(); hoisted.callGatewayMock.mockImplementation(async (argsUnknown: unknown) => { @@ -687,6 +718,244 @@ describe("spawnAcpDirect", () => { expect(transcriptCalls[1]?.threadId).toBe("child-thread"); }); + it("inherits subagent envelope fields onto ACP children", async () => { + replaceSpawnConfig({ + ...hoisted.state.cfg, + agents: { + defaults: { + ...hoisted.state.cfg.agents?.defaults, + subagents: { + ...hoisted.state.cfg.agents?.defaults?.subagents, + maxSpawnDepth: 2, + }, + }, + }, + }); + + const result = await spawnAcpDirect(createSpawnRequest(), { + ...createRequesterContext(), + agentSessionKey: "agent:main:subagent:parent", + }); + + const accepted = expectAcceptedSpawn(result); + const patchCall = hoisted.callGatewayMock.mock.calls + .map((call: unknown[]) => call[0] as { method?: string; params?: Record }) + .find((request) => request.method === "sessions.patch"); + expect(patchCall?.params).toMatchObject({ + key: accepted.childSessionKey, + spawnedBy: "agent:main:subagent:parent", + spawnDepth: 2, + subagentRole: "leaf", + subagentControlScope: "none", + }); + }); + + it("rejects ACP spawns that exceed subagent max depth", async () => { + replaceSpawnConfig({ + ...hoisted.state.cfg, + agents: { + defaults: { + ...hoisted.state.cfg.agents?.defaults, + subagents: { + ...hoisted.state.cfg.agents?.defaults?.subagents, + maxSpawnDepth: 2, + }, + }, + }, + }); + + const result = await spawnAcpDirect(createSpawnRequest(), { + ...createRequesterContext(), + agentSessionKey: "agent:main:subagent:parent:subagent:leaf", + }); + + const failed = expectFailedSpawn(result, "forbidden"); + expect(failed.errorCode).toBe("subagent_policy"); + expect(failed.error).toContain("current depth: 2, max: 2"); + }); + + it("rejects ACP spawns that exceed subagent child caps", async () => { + replaceSpawnConfig({ + ...hoisted.state.cfg, + agents: { + defaults: { + ...hoisted.state.cfg.agents?.defaults, + subagents: { + ...hoisted.state.cfg.agents?.defaults?.subagents, + maxChildrenPerAgent: 1, + }, + }, + }, + }); + hoisted.countActiveRunsForSessionMock.mockReturnValueOnce(1); + + const result = await spawnAcpDirect(createSpawnRequest(), { + ...createRequesterContext(), + agentSessionKey: "agent:main:subagent:parent", + }); + + const failed = expectFailedSpawn(result, "forbidden"); + expect(failed.errorCode).toBe("subagent_policy"); + expect(failed.error).toContain("max active children"); + }); + + it('counts streamTo="parent" ACP runs toward subagent child caps', async () => { + replaceSpawnConfig({ + ...hoisted.state.cfg, + agents: { + defaults: { + ...hoisted.state.cfg.agents?.defaults, + subagents: { + ...hoisted.state.cfg.agents?.defaults?.subagents, + maxChildrenPerAgent: 1, + }, + }, + }, + }); + hoisted.listTasksForOwnerKeyMock.mockReturnValueOnce([ + { + runtime: "acp", + status: "running", + childSessionKey: "agent:codex:acp:existing-parent-stream", + }, + ]); + + const result = await spawnAcpDirect( + createSpawnRequest({ + streamTo: "parent", + }), + { + ...createRequesterContext(), + agentSessionKey: "agent:main:subagent:parent", + }, + ); + + const failed = expectFailedSpawn(result, "forbidden"); + expect(failed.errorCode).toBe("subagent_policy"); + expect(failed.error).toContain("max active children"); + }); + + it("does not double-count duplicate ACP task rows for the same child session", async () => { + replaceSpawnConfig({ + ...hoisted.state.cfg, + agents: { + defaults: { + ...hoisted.state.cfg.agents?.defaults, + subagents: { + ...hoisted.state.cfg.agents?.defaults?.subagents, + maxChildrenPerAgent: 2, + }, + }, + }, + }); + hoisted.listTasksForOwnerKeyMock.mockReturnValueOnce([ + { + runtime: "acp", + status: "running", + childSessionKey: "agent:codex:acp:existing-parent-stream", + }, + { + runtime: "acp", + status: "queued", + childSessionKey: "agent:codex:acp:existing-parent-stream", + }, + ]); + + const result = await spawnAcpDirect( + createSpawnRequest({ + streamTo: "parent", + }), + { + ...createRequesterContext(), + agentSessionKey: "agent:main:subagent:parent", + }, + ); + + expectAcceptedSpawn(result); + }); + + it("does not double-count ACP task rows for active registry-tracked ACP children", async () => { + replaceSpawnConfig({ + ...hoisted.state.cfg, + agents: { + defaults: { + ...hoisted.state.cfg.agents?.defaults, + subagents: { + ...hoisted.state.cfg.agents?.defaults?.subagents, + maxChildrenPerAgent: 2, + }, + }, + }, + }); + hoisted.countActiveRunsForSessionMock.mockReturnValueOnce(1); + hoisted.getSubagentRunByChildSessionKeyMock.mockImplementationOnce((childSessionKey: string) => + childSessionKey === "agent:codex:acp:existing-parent-stream" + ? { + childSessionKey, + createdAt: Date.now(), + } + : null, + ); + hoisted.listTasksForOwnerKeyMock.mockReturnValueOnce([ + { + runtime: "acp", + status: "running", + childSessionKey: "agent:codex:acp:existing-parent-stream", + }, + ]); + + const result = await spawnAcpDirect( + createSpawnRequest({ + streamTo: "parent", + }), + { + ...createRequesterContext(), + agentSessionKey: "agent:main:subagent:parent", + }, + ); + + expectAcceptedSpawn(result); + }); + + it("rejects ACP spawns to agents outside the subagent allowlist", async () => { + replaceSpawnConfig({ + ...hoisted.state.cfg, + acp: { + ...hoisted.state.cfg.acp, + allowedAgents: ["codex", "writer"], + }, + agents: { + ...hoisted.state.cfg.agents, + list: [ + { + id: "main", + default: true, + subagents: { + allowAgents: ["codex"], + }, + }, + { + id: "writer", + }, + ], + }, + }); + + const result = await spawnAcpDirect( + createSpawnRequest({ + agentId: "writer", + }), + { + ...createRequesterContext(), + agentSessionKey: "agent:main:subagent:parent", + }, + ); + + const failed = expectFailedSpawn(result, "forbidden"); + expect(failed.errorCode).toBe("subagent_policy"); + expect(failed.error).toContain("agentId is not allowed"); + }); + it("spawns Matrix thread-bound ACP sessions from top-level room targets", async () => { enableMatrixAcpThreadBindings(); hoisted.sessionBindingBindMock.mockImplementationOnce( @@ -1522,6 +1791,7 @@ describe("spawnAcpDirect", () => { ...hoisted.state.cfg, agents: { defaults: { + ...hoisted.state.cfg.agents?.defaults, sandbox: { mode: "all" }, }, }, @@ -1623,6 +1893,7 @@ describe("spawnAcpDirect", () => { ...hoisted.state.cfg, agents: { defaults: { + ...hoisted.state.cfg.agents?.defaults, heartbeat: { every: "30m", target: "last", @@ -1701,11 +1972,81 @@ describe("spawnAcpDirect", () => { expect(secondHandle.notifyStarted).toHaveBeenCalledTimes(1); }); + it("does not implicitly stream for ACP requester sessions inside a subagent envelope", async () => { + replaceSpawnConfig({ + ...hoisted.state.cfg, + agents: { + defaults: { + ...hoisted.state.cfg.agents?.defaults, + heartbeat: { + every: "30m", + target: "last", + }, + }, + }, + }); + hoisted.loadSessionStoreMock.mockReset().mockImplementation(() => { + const store: Record< + string, + { + sessionId: string; + updatedAt: number; + deliveryContext?: unknown; + spawnedBy?: string; + spawnDepth?: number; + subagentRole?: string; + subagentControlScope?: string; + } + > = { + "agent:main:acp:child": { + sessionId: "parent-sess-1", + updatedAt: Date.now(), + deliveryContext: { + channel: "discord", + to: "channel:parent-channel", + accountId: "default", + }, + spawnedBy: "agent:main:subagent:parent", + spawnDepth: 2, + subagentRole: "leaf", + subagentControlScope: "none", + }, + }; + return new Proxy(store, { + get(target, prop) { + if (typeof prop === "string" && prop.startsWith("agent:codex:acp:")) { + return { sessionId: "sess-123", updatedAt: Date.now() }; + } + return target[prop as keyof typeof target]; + }, + }); + }); + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + }, + { + agentSessionKey: "agent:main:acp:child", + agentChannel: "discord", + agentAccountId: "default", + agentTo: "channel:parent-channel", + }, + ); + + const accepted = expectAcceptedSpawn(result); + expect(accepted.mode).toBe("run"); + expect(accepted.streamLogPath).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); + }); + it("does not implicitly stream when heartbeat target is not session-local", async () => { replaceSpawnConfig({ ...hoisted.state.cfg, agents: { defaults: { + ...hoisted.state.cfg.agents?.defaults, heartbeat: { every: "30m", target: "discord", @@ -1740,6 +2081,7 @@ describe("spawnAcpDirect", () => { }, agents: { defaults: { + ...hoisted.state.cfg.agents?.defaults, heartbeat: { every: "30m", target: "last", @@ -1768,6 +2110,7 @@ describe("spawnAcpDirect", () => { replaceSpawnConfig({ ...hoisted.state.cfg, agents: { + ...hoisted.state.cfg.agents, list: [{ id: "main", heartbeat: { every: "30m" } }, { id: "research" }], }, }); @@ -1792,6 +2135,7 @@ describe("spawnAcpDirect", () => { replaceSpawnConfig({ ...hoisted.state.cfg, agents: { + ...hoisted.state.cfg.agents, list: [ { id: "research", diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index e9132f63316..150baacd425 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -33,6 +33,10 @@ import { resolveThreadBindingSpawnPolicy, } from "../channels/thread-bindings-policy.js"; import { parseDurationMs } from "../cli/parse-duration.js"; +import { + DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT, + DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH, +} from "../config/agent-limits.js"; import { loadConfig } from "../config/config.js"; import { resolveStorePath } from "../config/sessions/paths.js"; import { loadSessionStore } from "../config/sessions/store.js"; @@ -60,6 +64,7 @@ import { normalizeOptionalString, } from "../shared/string-coerce.js"; import { createRunningTaskRun } from "../tasks/detached-task-runtime.js"; +import { listTasksForOwnerKey } from "../tasks/runtime-internal.js"; import { deliveryContextFromSession, formatConversationTarget, @@ -75,6 +80,14 @@ import { resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js"; import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js"; import { resolveRequesterOriginForChild } from "./spawn-requester-origin.js"; import { resolveSpawnedWorkspaceInheritance } from "./spawned-context.js"; +import { + isSubagentEnvelopeSession, + resolveSubagentCapabilities, + resolveSubagentCapabilityStore, + type SessionCapabilityStore, +} from "./subagent-capabilities.js"; +import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; +import { countActiveRunsForSession, getSubagentRunByChildSessionKey } from "./subagent-registry.js"; import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js"; const log = createSubsystemLogger("agents/acp-spawn"); @@ -117,6 +130,7 @@ export const ACP_SPAWN_ERROR_CODES = [ "acp_disabled", "requester_session_required", "runtime_policy", + "subagent_policy", "thread_required", "target_agent_required", "agent_forbidden", @@ -216,6 +230,52 @@ type AcpSpawnStreamPlan = { effectiveStreamToParent: boolean; }; +type AcpSubagentEnvelopeState = { + childSessionPatch?: { + spawnDepth: number; + subagentRole: "orchestrator" | "leaf" | null; + subagentControlScope: "children" | "none"; + }; + error?: string; +}; + +function isActiveTaskStatus(status: string | undefined): boolean { + return status === "queued" || status === "running"; +} + +function countUntrackedActiveAcpRunsForOwner(ownerKey: string | undefined): number { + const normalizedOwnerKey = normalizeOptionalString(ownerKey); + if (!normalizedOwnerKey) { + return 0; + } + const tasks = listTasksForOwnerKey(normalizedOwnerKey); + const trackedChildSessionKeys = new Set( + tasks + .filter( + (task) => + task.runtime === "subagent" && + isActiveTaskStatus(task.status) && + normalizeOptionalString(task.childSessionKey), + ) + .map((task) => normalizeOptionalString(task.childSessionKey) as string), + ); + const activeAcpChildSessionKeys = new Set( + tasks.flatMap((task) => { + const childSessionKey = normalizeOptionalString(task.childSessionKey); + const trackedRun = childSessionKey ? getSubagentRunByChildSessionKey(childSessionKey) : null; + const hasActiveRegistryRun = Boolean(trackedRun && typeof trackedRun.endedAt !== "number"); + return task.runtime === "acp" && + isActiveTaskStatus(task.status) && + childSessionKey !== undefined && + !hasActiveRegistryRun && + !trackedChildSessionKeys.has(childSessionKey) + ? [childSessionKey] + : []; + }), + ); + return activeAcpChildSessionKeys.size; +} + type AcpSpawnBootstrapDeliveryPlan = { useInlineDelivery: boolean; channel?: string; @@ -658,6 +718,7 @@ function resolveAcpSpawnRequesterState(params: { parentSessionKey?: string; targetAgentId: string; ctx: SpawnAcpContext; + subagentStore?: SessionCapabilityStore; }): AcpSpawnRequesterState { const bindingService = getSessionBindingService(); const requesterParsedSession = parseAgentSessionKey(params.parentSessionKey); @@ -706,6 +767,94 @@ function resolveAcpSpawnRequesterState(params: { }; } +function resolveAcpSubagentEnvelopeState(params: { + cfg: OpenClawConfig; + requesterSessionKey?: string; + targetAgentId: string; + requestedAgentId?: string; + subagentStore?: SessionCapabilityStore; +}): AcpSubagentEnvelopeState { + const requesterSessionKey = normalizeOptionalString(params.requesterSessionKey); + if (!requesterSessionKey) { + return {}; + } + if ( + !isSubagentEnvelopeSession(requesterSessionKey, { + cfg: params.cfg, + store: params.subagentStore, + }) + ) { + return {}; + } + + const callerDepth = getSubagentDepthFromSessionStore(requesterSessionKey, { + cfg: params.cfg, + }); + const maxSpawnDepth = + params.cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; + if (callerDepth >= maxSpawnDepth) { + return { + error: `sessions_spawn is not allowed at this depth (current depth: ${callerDepth}, max: ${maxSpawnDepth})`, + }; + } + + const maxChildren = + params.cfg.agents?.defaults?.subagents?.maxChildrenPerAgent ?? + DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT; + const activeChildren = + countActiveRunsForSession(requesterSessionKey) + + countUntrackedActiveAcpRunsForOwner(requesterSessionKey); + if (activeChildren >= maxChildren) { + return { + error: `sessions_spawn has reached max active children for this session (${activeChildren}/${maxChildren})`, + }; + } + + const requesterAgentId = normalizeAgentId(parseAgentSessionKey(requesterSessionKey)?.agentId); + const requireAgentId = + resolveAgentConfig(params.cfg, requesterAgentId)?.subagents?.requireAgentId ?? + params.cfg.agents?.defaults?.subagents?.requireAgentId ?? + false; + if (requireAgentId && !params.requestedAgentId?.trim()) { + return { + error: + "sessions_spawn requires explicit agentId when requireAgentId is configured. Use agents_list to see allowed agent ids.", + }; + } + + if (params.targetAgentId !== requesterAgentId) { + const allowAgents = + resolveAgentConfig(params.cfg, requesterAgentId)?.subagents?.allowAgents ?? + params.cfg.agents?.defaults?.subagents?.allowAgents ?? + []; + const allowAny = allowAgents.some((value) => value.trim() === "*"); + const normalizedTargetId = normalizeOptionalLowercaseString(params.targetAgentId) ?? ""; + const allowSet = new Set( + allowAgents + .filter((value) => value.trim() && value.trim() !== "*") + .map((value) => normalizeOptionalLowercaseString(normalizeAgentId(value)) ?? ""), + ); + if (!allowAny && !allowSet.has(normalizedTargetId)) { + const allowedText = allowSet.size > 0 ? Array.from(allowSet).join(", ") : "none"; + return { + error: `agentId is not allowed for sessions_spawn (allowed: ${allowedText})`, + }; + } + } + + const childCapabilities = resolveSubagentCapabilities({ + depth: callerDepth + 1, + maxSpawnDepth, + }); + return { + childSessionPatch: { + spawnDepth: childCapabilities.depth, + subagentRole: childCapabilities.role === "main" ? null : childCapabilities.role, + subagentControlScope: childCapabilities.controlScope, + }, + }; +} + function resolveAcpSpawnStreamPlan(params: { spawnMode: SpawnAcpMode; requestThreadBinding: boolean; @@ -1006,12 +1155,30 @@ export async function spawnAcpDirect( error: agentPolicyError.message, }); } + const subagentStore = resolveSubagentCapabilityStore(parentSessionKey, { + cfg, + }); const requesterState = resolveAcpSpawnRequesterState({ cfg, parentSessionKey, targetAgentId, ctx, + subagentStore, }); + const subagentEnvelopeState = resolveAcpSubagentEnvelopeState({ + cfg, + requesterSessionKey: requesterInternalKey, + targetAgentId, + requestedAgentId: params.agentId, + subagentStore, + }); + if (subagentEnvelopeState.error) { + return createAcpSpawnFailure({ + status: "forbidden", + errorCode: "subagent_policy", + error: subagentEnvelopeState.error, + }); + } const { effectiveStreamToParent } = resolveAcpSpawnStreamPlan({ spawnMode, requestThreadBinding, @@ -1070,6 +1237,7 @@ export async function spawnAcpDirect( params: { key: sessionKey, spawnedBy: requesterInternalKey, + ...subagentEnvelopeState.childSessionPatch, ...(params.label ? { label: params.label } : {}), }, timeoutMs: 10_000, diff --git a/src/agents/pi-embedded-runner/effective-tool-policy.ts b/src/agents/pi-embedded-runner/effective-tool-policy.ts index a355a7f49a8..7bb75db39d4 100644 --- a/src/agents/pi-embedded-runner/effective-tool-policy.ts +++ b/src/agents/pi-embedded-runner/effective-tool-policy.ts @@ -1,12 +1,15 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { getPluginToolMeta } from "../../plugins/tools.js"; -import { isSubagentSessionKey } from "../../routing/session-key.js"; import { resolveEffectiveToolPolicy, resolveGroupContextFromSessionKey, resolveGroupToolPolicy, resolveSubagentToolPolicyForSession, } from "../pi-tools.policy.js"; +import { + isSubagentEnvelopeSession, + resolveSubagentCapabilityStore, +} from "../subagent-capabilities.js"; import { applyToolPolicyPipeline, buildDefaultToolPolicyPipelineSteps, @@ -133,9 +136,18 @@ export function applyFinalEffectiveToolPolicy( providerProfilePolicy, providerProfileAlsoAllow, ); + const subagentStore = resolveSubagentCapabilityStore(params.sessionKey, { + cfg: params.config, + }); const subagentPolicy = - isSubagentSessionKey(params.sessionKey) && params.sessionKey - ? resolveSubagentToolPolicyForSession(params.config, params.sessionKey) + params.sessionKey && + isSubagentEnvelopeSession(params.sessionKey, { + cfg: params.config, + store: subagentStore, + }) + ? resolveSubagentToolPolicyForSession(params.config, params.sessionKey, { + store: subagentStore, + }) : undefined; const ownerFiltered = applyOwnerOnlyToolPolicy( params.bundledTools, diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts index 673c8fdfab9..045854eb13c 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts @@ -238,6 +238,183 @@ describe("createOpenClawCodingTools", () => { } }); + it("applies subagent tool policy to ACP children spawned under a subagent envelope", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acp-subagent-policy-")); + try { + const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json"); + const mainStorePath = storeTemplate.replaceAll("{agentId}", "main"); + const writerStorePath = storeTemplate.replaceAll("{agentId}", "writer"); + await fs.writeFile( + mainStorePath, + JSON.stringify( + { + "agent:main:acp:child": { + sessionId: "session-acp-child", + updatedAt: Date.now(), + spawnedBy: "agent:main:subagent:parent", + spawnDepth: 2, + subagentRole: "leaf", + subagentControlScope: "none", + }, + "agent:main:acp:plain": { + sessionId: "session-acp-plain", + updatedAt: Date.now(), + spawnedBy: "agent:main:main", + }, + "agent:main:acp:parent": { + sessionId: "session-acp-parent", + updatedAt: Date.now(), + spawnedBy: "agent:main:subagent:parent", + }, + }, + null, + 2, + ), + "utf-8", + ); + await fs.writeFile( + writerStorePath, + JSON.stringify( + { + "agent:writer:acp:child": { + sessionId: "session-acp-cross-agent-child", + updatedAt: Date.now(), + spawnedBy: "agent:main:acp:parent", + }, + }, + null, + 2, + ), + "utf-8", + ); + + const persistedEnvelopeTools = createOpenClawCodingTools({ + sessionKey: "agent:main:acp:child", + config: { + session: { + store: storeTemplate, + }, + agents: { + defaults: { + subagents: { + maxSpawnDepth: 2, + }, + }, + }, + }, + }); + const persistedEnvelopeNames = new Set(persistedEnvelopeTools.map((tool) => tool.name)); + expect(persistedEnvelopeNames.has("sessions_spawn")).toBe(false); + expect(persistedEnvelopeNames.has("sessions_list")).toBe(false); + expect(persistedEnvelopeNames.has("sessions_history")).toBe(false); + expect(persistedEnvelopeNames.has("subagents")).toBe(false); + + const restrictedTools = createOpenClawCodingTools({ + sessionKey: "agent:main:acp:plain", + config: { + session: { + store: storeTemplate, + }, + agents: { + defaults: { + subagents: { + maxSpawnDepth: 2, + }, + }, + }, + }, + }); + const restrictedNames = new Set(restrictedTools.map((tool) => tool.name)); + expect(restrictedNames.has("sessions_spawn")).toBe(true); + expect(restrictedNames.has("subagents")).toBe(true); + + const ancestryTools = createOpenClawCodingTools({ + sessionKey: "agent:writer:acp:child", + config: { + session: { + store: storeTemplate, + }, + agents: { + defaults: { + subagents: { + maxSpawnDepth: 2, + }, + }, + }, + }, + }); + const ancestryNames = new Set(ancestryTools.map((tool) => tool.name)); + expect(ancestryNames.has("sessions_spawn")).toBe(false); + expect(ancestryNames.has("sessions_list")).toBe(false); + expect(ancestryNames.has("sessions_history")).toBe(false); + expect(ancestryNames.has("subagents")).toBe(false); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("applies leaf tool policy for cross-agent subagent sessions when spawnDepth is missing", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cross-agent-subagent-")); + try { + const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json"); + const mainStorePath = storeTemplate.replaceAll("{agentId}", "main"); + const writerStorePath = storeTemplate.replaceAll("{agentId}", "writer"); + await fs.writeFile( + mainStorePath, + JSON.stringify( + { + "agent:main:subagent:parent": { + sessionId: "session-main-parent", + updatedAt: Date.now(), + spawnedBy: "agent:main:main", + }, + }, + null, + 2, + ), + "utf-8", + ); + await fs.writeFile( + writerStorePath, + JSON.stringify( + { + "agent:writer:subagent:child": { + sessionId: "session-writer-child", + updatedAt: Date.now(), + spawnedBy: "agent:main:subagent:parent", + }, + }, + null, + 2, + ), + "utf-8", + ); + + const tools = createOpenClawCodingTools({ + sessionKey: "agent:writer:subagent:child", + config: { + session: { + store: storeTemplate, + }, + agents: { + defaults: { + subagents: { + maxSpawnDepth: 2, + }, + }, + }, + }, + }); + const names = new Set(tools.map((tool) => tool.name)); + expect(names.has("sessions_spawn")).toBe(false); + expect(names.has("sessions_list")).toBe(false); + expect(names.has("sessions_history")).toBe(false); + expect(names.has("subagents")).toBe(false); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + it("supports allow-only sub-agent tool policy", () => { const tools = createOpenClawCodingTools({ sessionKey: "agent:main:subagent:test", diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index ac65d8bf924..c11187513ae 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -19,7 +19,9 @@ import type { AnyAgentTool } from "./pi-tools.types.js"; import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js"; import type { SandboxToolPolicy } from "./sandbox.js"; import { + resolveSubagentCapabilityStore, resolveStoredSubagentCapabilities, + type SessionCapabilityStore, type SubagentSessionRole, } from "./subagent-capabilities.js"; import { isToolAllowedByPolicies, isToolAllowedByPolicyName } from "./tool-policy-match.js"; @@ -100,9 +102,19 @@ export function resolveSubagentToolPolicy(cfg?: OpenClawConfig, depth?: number): export function resolveSubagentToolPolicyForSession( cfg: OpenClawConfig | undefined, sessionKey: string, + opts?: { + store?: SessionCapabilityStore; + }, ): SandboxToolPolicy { const configured = cfg?.tools?.subagents?.tools; - const capabilities = resolveStoredSubagentCapabilities(sessionKey, { cfg }); + const store = resolveSubagentCapabilityStore(sessionKey, { + cfg, + store: opts?.store, + }); + const capabilities = resolveStoredSubagentCapabilities(sessionKey, { + cfg, + store, + }); const allow = Array.isArray(configured?.allow) ? configured.allow : undefined; const alsoAllow = Array.isArray(configured?.alsoAllow) ? configured.alsoAllow : undefined; const explicitAllow = new Set( diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 3d17775f415..6898af6e9f9 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -5,7 +5,6 @@ import type { ToolLoopDetectionConfig } from "../config/types.tools.js"; import { resolveMergedSafeBinProfileFixtures } from "../infra/exec-safe-bin-runtime-policy.js"; import { logWarn } from "../logger.js"; import { getPluginToolMeta } from "../plugins/tools.js"; -import { isSubagentSessionKey } from "../routing/session-key.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, @@ -49,6 +48,10 @@ import { import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.schema.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; import type { SandboxContext } from "./sandbox.js"; +import { + isSubagentEnvelopeSession, + resolveSubagentCapabilityStore, +} from "./subagent-capabilities.js"; import { EXEC_TOOL_DISPLAY_SUMMARY, PROCESS_TOOL_DISPLAY_SUMMARY, @@ -395,9 +398,18 @@ export function createOpenClawCodingTools(options?: { // Fallback to agentId if no sessionKey is available (e.g. legacy or global contexts). const scopeKey = options?.exec?.scopeKey ?? options?.sessionKey ?? (agentId ? `agent:${agentId}` : undefined); + const subagentStore = resolveSubagentCapabilityStore(options?.sessionKey, { + cfg: options?.config, + }); const subagentPolicy = - isSubagentSessionKey(options?.sessionKey) && options?.sessionKey - ? resolveSubagentToolPolicyForSession(options.config, options.sessionKey) + options?.sessionKey && + isSubagentEnvelopeSession(options.sessionKey, { + cfg: options.config, + store: subagentStore, + }) + ? resolveSubagentToolPolicyForSession(options.config, options.sessionKey, { + store: subagentStore, + }) : undefined; const allowBackground = isToolAllowedByPolicies("process", [ profilePolicyWithAlsoAllow, diff --git a/src/agents/subagent-capabilities.ts b/src/agents/subagent-capabilities.ts index ffc6a4e9c98..65bd28b5da6 100644 --- a/src/agents/subagent-capabilities.ts +++ b/src/agents/subagent-capabilities.ts @@ -1,7 +1,11 @@ import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { isSubagentSessionKey, parseAgentSessionKey } from "../routing/session-key.js"; +import { + isAcpSessionKey, + isSubagentSessionKey, + parseAgentSessionKey, +} from "../routing/session-key.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; import { normalizeSubagentSessionKey } from "./subagent-session-key.js"; @@ -12,13 +16,16 @@ export type SubagentSessionRole = (typeof SUBAGENT_SESSION_ROLES)[number]; export const SUBAGENT_CONTROL_SCOPES = ["children", "none"] as const; export type SubagentControlScope = (typeof SUBAGENT_CONTROL_SCOPES)[number]; -type SessionCapabilityEntry = { +export type SessionCapabilityEntry = { sessionId?: unknown; spawnDepth?: unknown; subagentRole?: unknown; subagentControlScope?: unknown; + spawnedBy?: unknown; }; +export type SessionCapabilityStore = Record; + function normalizeSubagentRole(value: unknown): SubagentSessionRole | undefined { const trimmed = normalizeOptionalLowercaseString(value); return SUBAGENT_SESSION_ROLES.find((entry) => entry === trimmed); @@ -29,6 +36,20 @@ function normalizeSubagentControlScope(value: unknown): SubagentControlScope | u return SUBAGENT_CONTROL_SCOPES.find((entry) => entry === trimmed); } +function shouldInspectStoredSubagentEnvelope(sessionKey: string): boolean { + return isSubagentSessionKey(sessionKey) || isAcpSessionKey(sessionKey); +} + +function isSameAgentSessionStore(leftSessionKey: string, rightSessionKey: string): boolean { + const leftAgentId = normalizeOptionalLowercaseString( + parseAgentSessionKey(leftSessionKey)?.agentId, + ); + const rightAgentId = normalizeOptionalLowercaseString( + parseAgentSessionKey(rightSessionKey)?.agentId, + ); + return Boolean(leftAgentId) && leftAgentId === rightAgentId; +} + function readSessionStore(storePath: string): Record { try { return loadSessionStore(storePath); @@ -38,7 +59,7 @@ function readSessionStore(storePath: string): Record, + store: SessionCapabilityStore, sessionId: string, ): SessionCapabilityEntry | undefined { const normalizedSessionId = normalizeSubagentSessionKey(sessionId); @@ -57,7 +78,7 @@ function findEntryBySessionId( function resolveSessionCapabilityEntry(params: { sessionKey: string; cfg?: OpenClawConfig; - store?: Record; + store?: SessionCapabilityStore; }): SessionCapabilityEntry | undefined { if (params.store) { return params.store[params.sessionKey] ?? findEntryBySessionId(params.store, params.sessionKey); @@ -74,6 +95,31 @@ function resolveSessionCapabilityEntry(params: { return store[params.sessionKey] ?? findEntryBySessionId(store, params.sessionKey); } +export function resolveSubagentCapabilityStore( + sessionKey: string | undefined | null, + opts?: { + cfg?: OpenClawConfig; + store?: SessionCapabilityStore; + }, +): SessionCapabilityStore | undefined { + const normalizedSessionKey = normalizeSubagentSessionKey(sessionKey); + if (!normalizedSessionKey) { + return opts?.store; + } + if (opts?.store) { + return opts.store; + } + if (!opts?.cfg || !shouldInspectStoredSubagentEnvelope(normalizedSessionKey)) { + return undefined; + } + const parsed = parseAgentSessionKey(normalizedSessionKey); + if (!parsed?.agentId) { + return undefined; + } + const storePath = resolveStorePath(opts.cfg.session?.store, { agentId: parsed.agentId }); + return readSessionStore(storePath); +} + export function resolveSubagentRoleForDepth(params: { depth: number; maxSpawnDepth?: number; @@ -107,28 +153,122 @@ export function resolveSubagentCapabilities(params: { depth: number; maxSpawnDep }; } +function isStoredSubagentEnvelopeSession( + params: { + sessionKey: string; + cfg?: OpenClawConfig; + store?: SessionCapabilityStore; + entry?: SessionCapabilityEntry; + }, + visited = new Set(), +): boolean { + const normalizedSessionKey = normalizeSubagentSessionKey(params.sessionKey); + if (!normalizedSessionKey || visited.has(normalizedSessionKey)) { + return false; + } + visited.add(normalizedSessionKey); + + if (isSubagentSessionKey(normalizedSessionKey)) { + return true; + } + if (!isAcpSessionKey(normalizedSessionKey)) { + return false; + } + + const entry = + params.entry ?? + resolveSessionCapabilityEntry({ + sessionKey: normalizedSessionKey, + cfg: params.cfg, + store: params.store, + }); + if ( + normalizeSubagentRole(entry?.subagentRole) || + normalizeSubagentControlScope(entry?.subagentControlScope) + ) { + return true; + } + + const spawnedBy = normalizeSubagentSessionKey(entry?.spawnedBy); + if (!spawnedBy) { + return false; + } + const parentStore = isSameAgentSessionStore(normalizedSessionKey, spawnedBy) + ? params.store + : undefined; + return isStoredSubagentEnvelopeSession( + { + sessionKey: spawnedBy, + cfg: params.cfg, + store: parentStore, + }, + visited, + ); +} + +export function isSubagentEnvelopeSession( + sessionKey: string | undefined | null, + opts?: { + cfg?: OpenClawConfig; + store?: SessionCapabilityStore; + entry?: SessionCapabilityEntry; + }, +): boolean { + const normalizedSessionKey = normalizeSubagentSessionKey(sessionKey); + if (!normalizedSessionKey) { + return false; + } + if (isSubagentSessionKey(normalizedSessionKey)) { + return true; + } + if (!isAcpSessionKey(normalizedSessionKey)) { + return false; + } + const store = resolveSubagentCapabilityStore(normalizedSessionKey, opts); + return isStoredSubagentEnvelopeSession({ + sessionKey: normalizedSessionKey, + cfg: opts?.cfg, + store, + entry: opts?.entry, + }); +} + export function resolveStoredSubagentCapabilities( sessionKey: string | undefined | null, opts?: { cfg?: OpenClawConfig; - store?: Record; + store?: SessionCapabilityStore; }, ) { const normalizedSessionKey = normalizeSubagentSessionKey(sessionKey); const maxSpawnDepth = opts?.cfg?.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; - const depth = getSubagentDepthFromSessionStore(normalizedSessionKey, { - cfg: opts?.cfg, - store: opts?.store, - }); - if (!normalizedSessionKey || !isSubagentSessionKey(normalizedSessionKey)) { + if (!normalizedSessionKey) { + return resolveSubagentCapabilities({ depth: 0, maxSpawnDepth }); + } + if (!shouldInspectStoredSubagentEnvelope(normalizedSessionKey)) { + const depth = getSubagentDepthFromSessionStore(normalizedSessionKey, { + cfg: opts?.cfg, + store: opts?.store, + }); return resolveSubagentCapabilities({ depth, maxSpawnDepth }); } - const entry = resolveSessionCapabilityEntry({ - sessionKey: normalizedSessionKey, + const store = resolveSubagentCapabilityStore(normalizedSessionKey, opts); + const entry = normalizedSessionKey + ? resolveSessionCapabilityEntry({ + sessionKey: normalizedSessionKey, + cfg: opts?.cfg, + store, + }) + : undefined; + const depthStore = opts?.cfg && typeof entry?.spawnDepth !== "number" ? undefined : store; + const depth = getSubagentDepthFromSessionStore(normalizedSessionKey, { cfg: opts?.cfg, - store: opts?.store, + store: depthStore, }); + if (!isSubagentEnvelopeSession(normalizedSessionKey, { ...opts, store, entry })) { + return resolveSubagentCapabilities({ depth, maxSpawnDepth }); + } const storedRole = normalizeSubagentRole(entry?.subagentRole); const storedControlScope = normalizeSubagentControlScope(entry?.subagentControlScope); const fallback = resolveSubagentCapabilities({ depth, maxSpawnDepth }); diff --git a/src/agents/subagent-spawn.runtime.ts b/src/agents/subagent-spawn.runtime.ts index e5973519456..ccd382cbeba 100644 --- a/src/agents/subagent-spawn.runtime.ts +++ b/src/agents/subagent-spawn.runtime.ts @@ -1,5 +1,8 @@ export { formatThinkingLevels, normalizeThinkLevel } from "../auto-reply/thinking.js"; -export { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js"; +export { + DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT, + DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH, +} from "../config/agent-limits.js"; export { loadConfig } from "../config/config.js"; export { mergeSessionEntry, updateSessionStore } from "../config/sessions.js"; export { callGateway } from "../gateway/call.js"; diff --git a/src/agents/subagent-spawn.test-helpers.ts b/src/agents/subagent-spawn.test-helpers.ts index e1e46470232..160c8c6a434 100644 --- a/src/agents/subagent-spawn.test-helpers.ts +++ b/src/agents/subagent-spawn.test-helpers.ts @@ -159,6 +159,7 @@ export async function loadSubagentSpawnModuleForTest(params: { params.emitSessionLifecycleEventMock?.(...args), formatThinkingLevels: (levels: string[]) => levels.join(", "), normalizeThinkLevel: (level: unknown) => normalizeOptionalString(level), + DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT: 5, DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH: 3, ADMIN_SCOPE: "operator.admin", AGENT_LANE_SUBAGENT: "subagent", diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index f577f29c336..aedd8254a98 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -36,6 +36,7 @@ import { import { ADMIN_SCOPE, AGENT_LANE_SUBAGENT, + DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT, DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH, buildSubagentSystemPrompt, callGateway, @@ -436,7 +437,8 @@ export async function spawnSubagentDirect( }; } - const maxChildren = cfg.agents?.defaults?.subagents?.maxChildrenPerAgent ?? 5; + const maxChildren = + cfg.agents?.defaults?.subagents?.maxChildrenPerAgent ?? DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT; const activeChildren = countActiveRunsForSession(requesterInternalKey); if (activeChildren >= maxChildren) { return { diff --git a/src/cli/plugins-cli.list.test.ts b/src/cli/plugins-cli.list.test.ts index feb1b8fd954..c0068ed1bdf 100644 --- a/src/cli/plugins-cli.list.test.ts +++ b/src/cli/plugins-cli.list.test.ts @@ -29,7 +29,15 @@ describe("plugins cli list", () => { await runPluginsCommand(["plugins", "list", "--json"]); - expect(buildPluginSnapshotReport).toHaveBeenCalledWith(); + expect(buildPluginSnapshotReport).toHaveBeenCalledWith( + expect.objectContaining({ + logger: expect.objectContaining({ + info: expect.any(Function), + warn: expect.any(Function), + error: expect.any(Function), + }), + }), + ); expect(JSON.parse(runtimeLogs[0] ?? "null")).toEqual({ workspaceDir: "/workspace", diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 384284243bd..b67e4632c93 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -16,6 +16,7 @@ import { buildPluginSnapshotReport, formatPluginCompatibilityNotice, } from "../plugins/status.js"; +import type { PluginLogger } from "../plugins/types.js"; import { resolveUninstallChannelConfigKeys, resolveUninstallDirectoryTarget, @@ -66,6 +67,13 @@ export type PluginUninstallOptions = { dryRun?: boolean; }; +const quietPluginJsonLogger: PluginLogger = { + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, +}; + function formatInspectSection(title: string, lines: string[]): string[] { if (lines.length === 0) { return []; @@ -144,7 +152,9 @@ export function registerPluginsCli(program: Command) { .option("--enabled", "Only show enabled plugins", false) .option("--verbose", "Show detailed entries", false) .action((opts: PluginsListOptions) => { - const report = buildPluginSnapshotReport(); + const report = buildPluginSnapshotReport( + opts.json ? { logger: quietPluginJsonLogger } : undefined, + ); const list = opts.enabled ? report.plugins.filter((p) => p.status === "loaded") : report.plugins; @@ -246,7 +256,10 @@ export function registerPluginsCli(program: Command) { .option("--json", "Print JSON") .action((id: string | undefined, opts: PluginInspectOptions) => { const cfg = loadConfig(); - const report = buildPluginDiagnosticsReport({ config: cfg }); + const report = buildPluginDiagnosticsReport({ + config: cfg, + ...(opts.json ? { logger: quietPluginJsonLogger } : {}), + }); if (opts.all) { if (id) { defaultRuntime.error("Pass either a plugin id or --all, not both."); @@ -254,6 +267,7 @@ export function registerPluginsCli(program: Command) { } const inspectAll = buildAllPluginInspectReports({ config: cfg, + ...(opts.json ? { logger: quietPluginJsonLogger } : {}), report, }); const inspectAllWithInstall = inspectAll.map((inspect) => ({ @@ -322,6 +336,7 @@ export function registerPluginsCli(program: Command) { const inspect = buildPluginInspectReport({ id, config: cfg, + ...(opts.json ? { logger: quietPluginJsonLogger } : {}), report, }); if (!inspect) { diff --git a/src/config/agent-limits.ts b/src/config/agent-limits.ts index bc0f0aa2e79..0e3d4bcd896 100644 --- a/src/config/agent-limits.ts +++ b/src/config/agent-limits.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "./types.js"; export const DEFAULT_AGENT_MAX_CONCURRENT = 4; export const DEFAULT_SUBAGENT_MAX_CONCURRENT = 8; +export const DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT = 5; // Keep depth-1 subagents as leaves unless config explicitly opts into nesting. export const DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH = 1; diff --git a/src/gateway/tool-resolution.ts b/src/gateway/tool-resolution.ts index e5ba5fb6755..08dd453d19a 100644 --- a/src/gateway/tool-resolution.ts +++ b/src/gateway/tool-resolution.ts @@ -3,8 +3,12 @@ import { createOpenClawTools } from "../agents/openclaw-tools.js"; import { resolveEffectiveToolPolicy, resolveGroupToolPolicy, - resolveSubagentToolPolicy, + resolveSubagentToolPolicyForSession, } from "../agents/pi-tools.policy.js"; +import { + isSubagentEnvelopeSession, + resolveSubagentCapabilityStore, +} from "../agents/subagent-capabilities.js"; import { applyToolPolicyPipeline, buildDefaultToolPolicyPipelineSteps, @@ -18,7 +22,6 @@ import type { AnyAgentTool } from "../agents/tools/common.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logWarn } from "../logger.js"; import { getPluginToolMeta } from "../plugins/tools.js"; -import { isSubagentSessionKey } from "../routing/session-key.js"; import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "../security/dangerous-tools.js"; export type GatewayScopedToolSurface = "http" | "loopback"; @@ -61,8 +64,16 @@ export function resolveGatewayScopedTools(params: { messageProvider: params.messageProvider, accountId: params.accountId ?? null, }); - const subagentPolicy = isSubagentSessionKey(params.sessionKey) - ? resolveSubagentToolPolicy(params.cfg) + const subagentStore = resolveSubagentCapabilityStore(params.sessionKey, { + cfg: params.cfg, + }); + const subagentPolicy = isSubagentEnvelopeSession(params.sessionKey, { + cfg: params.cfg, + store: subagentStore, + }) + ? resolveSubagentToolPolicyForSession(params.cfg, params.sessionKey, { + store: subagentStore, + }) : undefined; const workspaceDir = resolveAgentWorkspaceDir( params.cfg, diff --git a/src/plugins/runtime/metadata-registry-loader.test.ts b/src/plugins/runtime/metadata-registry-loader.test.ts index 260346fdda0..873d668a592 100644 --- a/src/plugins/runtime/metadata-registry-loader.test.ts +++ b/src/plugins/runtime/metadata-registry-loader.test.ts @@ -79,6 +79,29 @@ describe("loadPluginMetadataRegistrySnapshot", () => { ); }); + it("forwards an explicit logger through metadata snapshots", () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + loadPluginMetadataRegistrySnapshot({ + config: { plugins: {} }, + logger, + workspaceDir: "/workspace", + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: { plugins: {} }, + logger, + workspaceDir: "/workspace", + mode: "validate", + }), + ); + }); + it("preserves explicit empty plugin scopes on metadata snapshots", () => { loadPluginMetadataRegistrySnapshot({ config: { plugins: {} }, diff --git a/src/plugins/runtime/metadata-registry-loader.ts b/src/plugins/runtime/metadata-registry-loader.ts index 5883fe55bf0..5723037ba1f 100644 --- a/src/plugins/runtime/metadata-registry-loader.ts +++ b/src/plugins/runtime/metadata-registry-loader.ts @@ -2,12 +2,14 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { loadOpenClawPlugins } from "../loader.js"; import { hasExplicitPluginIdScope } from "../plugin-scope.js"; import type { PluginRegistry } from "../registry.js"; +import type { PluginLogger } from "../types.js"; import { buildPluginRuntimeLoadOptions, resolvePluginRuntimeLoadContext } from "./load-context.js"; export function loadPluginMetadataRegistrySnapshot(options?: { config?: OpenClawConfig; activationSourceConfig?: OpenClawConfig; env?: NodeJS.ProcessEnv; + logger?: PluginLogger; workspaceDir?: string; onlyPluginIds?: string[]; loadModules?: boolean; diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index bbed67b61b0..fd8955de044 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -111,6 +111,7 @@ function expectPluginLoaderCall(params: { autoEnabledReasons?: Record; workspaceDir?: string; env?: NodeJS.ProcessEnv; + logger?: unknown; loadModules?: boolean; }) { expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( @@ -124,6 +125,7 @@ function expectPluginLoaderCall(params: { : {}), ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), ...(params.env ? { env: params.env } : {}), + ...(params.logger !== undefined ? { logger: params.logger } : {}), ...(params.loadModules !== undefined ? { loadModules: params.loadModules } : {}), }), ); @@ -134,6 +136,7 @@ function expectMetadataSnapshotLoaderCall(params: { activationSourceConfig?: unknown; workspaceDir?: string; env?: NodeJS.ProcessEnv; + logger?: unknown; loadModules?: boolean; }) { expect(loadPluginMetadataRegistrySnapshotMock).toHaveBeenCalledWith( @@ -144,6 +147,7 @@ function expectMetadataSnapshotLoaderCall(params: { : {}), ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), ...(params.env ? { env: params.env } : {}), + ...(params.logger !== undefined ? { logger: params.logger } : {}), ...(params.loadModules !== undefined ? { loadModules: params.loadModules } : {}), }), ); @@ -367,6 +371,27 @@ describe("plugin status reports", () => { }); }); + it("forwards an explicit logger to plugin loading", () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + buildPluginSnapshotReport({ + config: {}, + logger, + workspaceDir: "/workspace", + }); + + expectMetadataSnapshotLoaderCall({ + config: {}, + logger, + workspaceDir: "/workspace", + loadModules: false, + }); + }); + it("uses a metadata snapshot load for snapshot reports", () => { buildPluginSnapshotReport({ config: {}, workspaceDir: "/workspace" }); diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 0c5aead25c3..d65eeb1046a 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -26,7 +26,7 @@ import { resolvePluginRuntimeLoadContext, } from "./runtime/load-context.js"; import { loadPluginMetadataRegistrySnapshot } from "./runtime/metadata-registry-loader.js"; -import type { PluginHookName } from "./types.js"; +import type { PluginHookName, PluginLogger } from "./types.js"; export type PluginStatusReport = PluginRegistry & { workspaceDir?: string; @@ -134,6 +134,7 @@ type PluginReportParams = { workspaceDir?: string; /** Use an explicit env when plugin roots should resolve independently from process.env. */ env?: NodeJS.ProcessEnv; + logger?: PluginLogger; }; function buildPluginReport( @@ -143,6 +144,7 @@ function buildPluginReport( const baseContext = resolvePluginRuntimeLoadContext({ config: params?.config ?? loadConfig(), env: params?.env, + logger: params?.logger, workspaceDir: params?.workspaceDir, }); const workspaceDir = baseContext.workspaceDir ?? resolveDefaultAgentWorkspaceDir(); @@ -193,6 +195,7 @@ function buildPluginReport( activationSourceConfig: rawConfig, workspaceDir, env: params?.env, + logger: params?.logger, loadModules: false, }); const importedPluginIds = new Set([ @@ -230,18 +233,21 @@ export function buildPluginInspectReport(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + logger?: PluginLogger; report?: PluginStatusReport; }): PluginInspectReport | null { const rawConfig = params.config ?? loadConfig(); const config = resolvePluginRuntimeLoadContext({ config: rawConfig, env: params.env, + logger: params.logger, workspaceDir: params.workspaceDir, }).config; const report = params.report ?? buildPluginDiagnosticsReport({ config: rawConfig, + logger: params.logger, workspaceDir: params.workspaceDir, env: params.env, }); @@ -355,6 +361,7 @@ export function buildAllPluginInspectReports(params?: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + logger?: PluginLogger; report?: PluginStatusReport; }): PluginInspectReport[] { const rawConfig = params?.config ?? loadConfig(); @@ -362,6 +369,7 @@ export function buildAllPluginInspectReports(params?: { params?.report ?? buildPluginDiagnosticsReport({ config: rawConfig, + logger: params?.logger, workspaceDir: params?.workspaceDir, env: params?.env, }); @@ -371,6 +379,7 @@ export function buildAllPluginInspectReports(params?: { buildPluginInspectReport({ id: plugin.id, config: rawConfig, + logger: params?.logger, report, }), ) @@ -381,6 +390,7 @@ export function buildPluginCompatibilityWarnings(params?: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + logger?: PluginLogger; report?: PluginStatusReport; }): string[] { return buildPluginCompatibilityNotices(params).map(formatPluginCompatibilityNotice); @@ -390,6 +400,7 @@ export function buildPluginCompatibilityNotices(params?: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + logger?: PluginLogger; report?: PluginStatusReport; }): PluginCompatibilityNotice[] { return buildAllPluginInspectReports(params).flatMap((inspect) => inspect.compatibility);