Skip to content

Commit 31160dc

Browse files
authored
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
1 parent 89b6d02 commit 31160dc

18 files changed

Lines changed: 996 additions & 29 deletions

src/agents/acp-spawn.test.ts

Lines changed: 344 additions & 0 deletions
Large diffs are not rendered by default.

src/agents/acp-spawn.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ import {
3333
resolveThreadBindingSpawnPolicy,
3434
} from "../channels/thread-bindings-policy.js";
3535
import { parseDurationMs } from "../cli/parse-duration.js";
36+
import {
37+
DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT,
38+
DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH,
39+
} from "../config/agent-limits.js";
3640
import { loadConfig } from "../config/config.js";
3741
import { resolveStorePath } from "../config/sessions/paths.js";
3842
import { loadSessionStore } from "../config/sessions/store.js";
@@ -60,6 +64,7 @@ import {
6064
normalizeOptionalString,
6165
} from "../shared/string-coerce.js";
6266
import { createRunningTaskRun } from "../tasks/detached-task-runtime.js";
67+
import { listTasksForOwnerKey } from "../tasks/runtime-internal.js";
6368
import {
6469
deliveryContextFromSession,
6570
formatConversationTarget,
@@ -75,6 +80,14 @@ import { resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js";
7580
import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js";
7681
import { resolveRequesterOriginForChild } from "./spawn-requester-origin.js";
7782
import { resolveSpawnedWorkspaceInheritance } from "./spawned-context.js";
83+
import {
84+
isSubagentEnvelopeSession,
85+
resolveSubagentCapabilities,
86+
resolveSubagentCapabilityStore,
87+
type SessionCapabilityStore,
88+
} from "./subagent-capabilities.js";
89+
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
90+
import { countActiveRunsForSession, getSubagentRunByChildSessionKey } from "./subagent-registry.js";
7891
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js";
7992

8093
const log = createSubsystemLogger("agents/acp-spawn");
@@ -117,6 +130,7 @@ export const ACP_SPAWN_ERROR_CODES = [
117130
"acp_disabled",
118131
"requester_session_required",
119132
"runtime_policy",
133+
"subagent_policy",
120134
"thread_required",
121135
"target_agent_required",
122136
"agent_forbidden",
@@ -216,6 +230,52 @@ type AcpSpawnStreamPlan = {
216230
effectiveStreamToParent: boolean;
217231
};
218232

233+
type AcpSubagentEnvelopeState = {
234+
childSessionPatch?: {
235+
spawnDepth: number;
236+
subagentRole: "orchestrator" | "leaf" | null;
237+
subagentControlScope: "children" | "none";
238+
};
239+
error?: string;
240+
};
241+
242+
function isActiveTaskStatus(status: string | undefined): boolean {
243+
return status === "queued" || status === "running";
244+
}
245+
246+
function countUntrackedActiveAcpRunsForOwner(ownerKey: string | undefined): number {
247+
const normalizedOwnerKey = normalizeOptionalString(ownerKey);
248+
if (!normalizedOwnerKey) {
249+
return 0;
250+
}
251+
const tasks = listTasksForOwnerKey(normalizedOwnerKey);
252+
const trackedChildSessionKeys = new Set(
253+
tasks
254+
.filter(
255+
(task) =>
256+
task.runtime === "subagent" &&
257+
isActiveTaskStatus(task.status) &&
258+
normalizeOptionalString(task.childSessionKey),
259+
)
260+
.map((task) => normalizeOptionalString(task.childSessionKey) as string),
261+
);
262+
const activeAcpChildSessionKeys = new Set(
263+
tasks.flatMap((task) => {
264+
const childSessionKey = normalizeOptionalString(task.childSessionKey);
265+
const trackedRun = childSessionKey ? getSubagentRunByChildSessionKey(childSessionKey) : null;
266+
const hasActiveRegistryRun = Boolean(trackedRun && typeof trackedRun.endedAt !== "number");
267+
return task.runtime === "acp" &&
268+
isActiveTaskStatus(task.status) &&
269+
childSessionKey !== undefined &&
270+
!hasActiveRegistryRun &&
271+
!trackedChildSessionKeys.has(childSessionKey)
272+
? [childSessionKey]
273+
: [];
274+
}),
275+
);
276+
return activeAcpChildSessionKeys.size;
277+
}
278+
219279
type AcpSpawnBootstrapDeliveryPlan = {
220280
useInlineDelivery: boolean;
221281
channel?: string;
@@ -658,6 +718,7 @@ function resolveAcpSpawnRequesterState(params: {
658718
parentSessionKey?: string;
659719
targetAgentId: string;
660720
ctx: SpawnAcpContext;
721+
subagentStore?: SessionCapabilityStore;
661722
}): AcpSpawnRequesterState {
662723
const bindingService = getSessionBindingService();
663724
const requesterParsedSession = parseAgentSessionKey(params.parentSessionKey);
@@ -706,6 +767,94 @@ function resolveAcpSpawnRequesterState(params: {
706767
};
707768
}
708769

770+
function resolveAcpSubagentEnvelopeState(params: {
771+
cfg: OpenClawConfig;
772+
requesterSessionKey?: string;
773+
targetAgentId: string;
774+
requestedAgentId?: string;
775+
subagentStore?: SessionCapabilityStore;
776+
}): AcpSubagentEnvelopeState {
777+
const requesterSessionKey = normalizeOptionalString(params.requesterSessionKey);
778+
if (!requesterSessionKey) {
779+
return {};
780+
}
781+
if (
782+
!isSubagentEnvelopeSession(requesterSessionKey, {
783+
cfg: params.cfg,
784+
store: params.subagentStore,
785+
})
786+
) {
787+
return {};
788+
}
789+
790+
const callerDepth = getSubagentDepthFromSessionStore(requesterSessionKey, {
791+
cfg: params.cfg,
792+
});
793+
const maxSpawnDepth =
794+
params.cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
795+
if (callerDepth >= maxSpawnDepth) {
796+
return {
797+
error: `sessions_spawn is not allowed at this depth (current depth: ${callerDepth}, max: ${maxSpawnDepth})`,
798+
};
799+
}
800+
801+
const maxChildren =
802+
params.cfg.agents?.defaults?.subagents?.maxChildrenPerAgent ??
803+
DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT;
804+
const activeChildren =
805+
countActiveRunsForSession(requesterSessionKey) +
806+
countUntrackedActiveAcpRunsForOwner(requesterSessionKey);
807+
if (activeChildren >= maxChildren) {
808+
return {
809+
error: `sessions_spawn has reached max active children for this session (${activeChildren}/${maxChildren})`,
810+
};
811+
}
812+
813+
const requesterAgentId = normalizeAgentId(parseAgentSessionKey(requesterSessionKey)?.agentId);
814+
const requireAgentId =
815+
resolveAgentConfig(params.cfg, requesterAgentId)?.subagents?.requireAgentId ??
816+
params.cfg.agents?.defaults?.subagents?.requireAgentId ??
817+
false;
818+
if (requireAgentId && !params.requestedAgentId?.trim()) {
819+
return {
820+
error:
821+
"sessions_spawn requires explicit agentId when requireAgentId is configured. Use agents_list to see allowed agent ids.",
822+
};
823+
}
824+
825+
if (params.targetAgentId !== requesterAgentId) {
826+
const allowAgents =
827+
resolveAgentConfig(params.cfg, requesterAgentId)?.subagents?.allowAgents ??
828+
params.cfg.agents?.defaults?.subagents?.allowAgents ??
829+
[];
830+
const allowAny = allowAgents.some((value) => value.trim() === "*");
831+
const normalizedTargetId = normalizeOptionalLowercaseString(params.targetAgentId) ?? "";
832+
const allowSet = new Set(
833+
allowAgents
834+
.filter((value) => value.trim() && value.trim() !== "*")
835+
.map((value) => normalizeOptionalLowercaseString(normalizeAgentId(value)) ?? ""),
836+
);
837+
if (!allowAny && !allowSet.has(normalizedTargetId)) {
838+
const allowedText = allowSet.size > 0 ? Array.from(allowSet).join(", ") : "none";
839+
return {
840+
error: `agentId is not allowed for sessions_spawn (allowed: ${allowedText})`,
841+
};
842+
}
843+
}
844+
845+
const childCapabilities = resolveSubagentCapabilities({
846+
depth: callerDepth + 1,
847+
maxSpawnDepth,
848+
});
849+
return {
850+
childSessionPatch: {
851+
spawnDepth: childCapabilities.depth,
852+
subagentRole: childCapabilities.role === "main" ? null : childCapabilities.role,
853+
subagentControlScope: childCapabilities.controlScope,
854+
},
855+
};
856+
}
857+
709858
function resolveAcpSpawnStreamPlan(params: {
710859
spawnMode: SpawnAcpMode;
711860
requestThreadBinding: boolean;
@@ -1006,12 +1155,30 @@ export async function spawnAcpDirect(
10061155
error: agentPolicyError.message,
10071156
});
10081157
}
1158+
const subagentStore = resolveSubagentCapabilityStore(parentSessionKey, {
1159+
cfg,
1160+
});
10091161
const requesterState = resolveAcpSpawnRequesterState({
10101162
cfg,
10111163
parentSessionKey,
10121164
targetAgentId,
10131165
ctx,
1166+
subagentStore,
1167+
});
1168+
const subagentEnvelopeState = resolveAcpSubagentEnvelopeState({
1169+
cfg,
1170+
requesterSessionKey: requesterInternalKey,
1171+
targetAgentId,
1172+
requestedAgentId: params.agentId,
1173+
subagentStore,
10141174
});
1175+
if (subagentEnvelopeState.error) {
1176+
return createAcpSpawnFailure({
1177+
status: "forbidden",
1178+
errorCode: "subagent_policy",
1179+
error: subagentEnvelopeState.error,
1180+
});
1181+
}
10151182
const { effectiveStreamToParent } = resolveAcpSpawnStreamPlan({
10161183
spawnMode,
10171184
requestThreadBinding,
@@ -1070,6 +1237,7 @@ export async function spawnAcpDirect(
10701237
params: {
10711238
key: sessionKey,
10721239
spawnedBy: requesterInternalKey,
1240+
...subagentEnvelopeState.childSessionPatch,
10731241
...(params.label ? { label: params.label } : {}),
10741242
},
10751243
timeoutMs: 10_000,

src/agents/pi-embedded-runner/effective-tool-policy.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import type { OpenClawConfig } from "../../config/types.openclaw.js";
22
import { getPluginToolMeta } from "../../plugins/tools.js";
3-
import { isSubagentSessionKey } from "../../routing/session-key.js";
43
import {
54
resolveEffectiveToolPolicy,
65
resolveGroupContextFromSessionKey,
76
resolveGroupToolPolicy,
87
resolveSubagentToolPolicyForSession,
98
} from "../pi-tools.policy.js";
9+
import {
10+
isSubagentEnvelopeSession,
11+
resolveSubagentCapabilityStore,
12+
} from "../subagent-capabilities.js";
1013
import {
1114
applyToolPolicyPipeline,
1215
buildDefaultToolPolicyPipelineSteps,
@@ -133,9 +136,18 @@ export function applyFinalEffectiveToolPolicy(
133136
providerProfilePolicy,
134137
providerProfileAlsoAllow,
135138
);
139+
const subagentStore = resolveSubagentCapabilityStore(params.sessionKey, {
140+
cfg: params.config,
141+
});
136142
const subagentPolicy =
137-
isSubagentSessionKey(params.sessionKey) && params.sessionKey
138-
? resolveSubagentToolPolicyForSession(params.config, params.sessionKey)
143+
params.sessionKey &&
144+
isSubagentEnvelopeSession(params.sessionKey, {
145+
cfg: params.config,
146+
store: subagentStore,
147+
})
148+
? resolveSubagentToolPolicyForSession(params.config, params.sessionKey, {
149+
store: subagentStore,
150+
})
139151
: undefined;
140152
const ownerFiltered = applyOwnerOnlyToolPolicy(
141153
params.bundledTools,

0 commit comments

Comments
 (0)