mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
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
This commit is contained in:
committed by
GitHub
parent
89b6d02481
commit
31160dc069
@@ -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<typeof spawnAcpDirect>[0];
|
||||
type SpawnContext = Parameters<typeof spawnAcpDirect>[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<string, unknown> })
|
||||
.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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, SessionCapabilityEntry>;
|
||||
|
||||
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<string, SessionCapabilityEntry> {
|
||||
try {
|
||||
return loadSessionStore(storePath);
|
||||
@@ -38,7 +59,7 @@ function readSessionStore(storePath: string): Record<string, SessionCapabilityEn
|
||||
}
|
||||
|
||||
function findEntryBySessionId(
|
||||
store: Record<string, SessionCapabilityEntry>,
|
||||
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<string, SessionCapabilityEntry>;
|
||||
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<string>(),
|
||||
): 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<string, SessionCapabilityEntry>;
|
||||
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 });
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {} },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -111,6 +111,7 @@ function expectPluginLoaderCall(params: {
|
||||
autoEnabledReasons?: Record<string, string[]>;
|
||||
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" });
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user