Files
openclaw/src/agents/spawn-plan.ts
Peter Steinberger 1ced7441eb refactor(agents): move announce, completion, recovery, and registry families into concept directories (#121553)
* refactor(agents): move announce family into subagents/announce/

* refactor(agents): move completion and main-session recovery families

* refactor(agents): move registry family into subagents/registry

* refactor(agents): update registry state type imports

* style(agents): format moved subagent imports

* test(agents): preserve isolated registry test routing

* fix(scripts): recognize relocated subagent announce seams
2026-08-10 16:06:39 -07:00

412 lines
14 KiB
TypeScript

import crypto from "node:crypto";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import {
resolveChannelDefaultBindingPlacement,
resolveInboundConversationResolution,
} from "../channels/conversation-resolution.js";
import {
formatThreadBindingDisabledError,
formatThreadBindingSpawnDisabledError,
resolveThreadBindingSpawnPolicy,
} from "../channels/thread-bindings-policy.js";
import {
DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT,
DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH,
} from "../config/agent-limits.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { getSessionBindingService } from "../infra/outbound/session-binding-service.js";
import { resolveAgentConfig } from "./agent-scope.js";
import { resolveChildAdmission, type ChildAdmissionCap } from "./child-admission.js";
import { countActiveRunsForSession } from "./subagents/registry/subagent-registry.js";
import { resolveSubagentCapabilities } from "./subagents/spawn/subagent-capabilities.js";
import { getSubagentDepthFromSessionStore } from "./subagents/spawn/subagent-depth.js";
import { resolveSubagentTargetPolicy } from "./subagents/spawn/subagent-target-policy.js";
type SpawnMode = "run" | "session";
type SpawnBackendKind = "subagent" | "acp";
export type PreparedSpawnThreadBinding = {
channel: string;
accountId: string;
placement: "current" | "child";
conversationId: string;
parentConversationId?: string;
};
type SessionBindingService = ReturnType<typeof getSessionBindingService>;
export function resolveSpawnMode(params: {
requestedMode?: SpawnMode;
threadRequested: boolean;
}): SpawnMode {
if (params.requestedMode === "run" || params.requestedMode === "session") {
return params.requestedMode;
}
return params.threadRequested ? "session" : "run";
}
export function mintSpawnSessionKey(params: {
targetAgentId: string;
backend: SpawnBackendKind;
}): string {
const kind = params.backend === "acp" ? "acp" : "subagent";
return `agent:${params.targetAgentId}:${kind}:${crypto.randomUUID()}`;
}
export function resolveSpawnChannelAccountId(params: {
cfg: OpenClawConfig;
channel?: string;
accountId?: string;
}): string | undefined {
const channel = normalizeOptionalLowercaseString(params.channel);
const explicitAccountId = normalizeOptionalString(params.accountId);
if (explicitAccountId) {
return explicitAccountId;
}
if (!channel) {
return undefined;
}
const channels = params.cfg.channels as Record<string, { defaultAccount?: unknown } | undefined>;
return normalizeOptionalString(channels?.[channel]?.defaultAccount) ?? "default";
}
export function resolveConversationRefForThreadBinding(params: {
cfg: OpenClawConfig;
channel?: string;
accountId?: string;
to?: string;
threadId?: string | number;
groupId?: string;
}): { conversationId: string; parentConversationId?: string } | null {
const resolution = resolveInboundConversationResolution({
cfg: params.cfg,
channel: params.channel,
accountId: params.accountId,
to: params.to,
threadId: params.threadId,
groupId: params.groupId,
isGroup: true,
});
return resolution?.canonical ?? null;
}
function resolveRequesterBoundConversationRef(params: {
bindingService: SessionBindingService;
requesterSessionKey?: string;
channel: string;
accountId: string;
fallback?: { conversationId: string; parentConversationId?: string } | null;
}): { conversationId: string; parentConversationId?: string } | null | undefined {
const requesterSessionKey = normalizeOptionalString(params.requesterSessionKey);
if (!requesterSessionKey) {
return undefined;
}
const activeBindings = params.bindingService
.listBySession(requesterSessionKey)
.filter(
(record) =>
record.status !== "ended" &&
record.conversation.channel === params.channel &&
(record.conversation.accountId ?? params.accountId) === params.accountId,
);
if (activeBindings.length === 0) {
return undefined;
}
if (activeBindings.length === 1) {
const conversation = activeBindings[0]?.conversation;
return conversation
? {
conversationId: conversation.conversationId,
...(conversation.parentConversationId
? { parentConversationId: conversation.parentConversationId }
: {}),
}
: undefined;
}
if (!params.fallback?.conversationId) {
return null;
}
const matched = activeBindings.filter(
(record) =>
record.conversation.conversationId === params.fallback?.conversationId &&
normalizeOptionalString(record.conversation.parentConversationId) ===
normalizeOptionalString(params.fallback?.parentConversationId),
);
const conversation = matched.length === 1 ? matched[0]?.conversation : undefined;
return conversation
? {
conversationId: conversation.conversationId,
...(conversation.parentConversationId
? { parentConversationId: conversation.parentConversationId }
: {}),
}
: null;
}
function buildThreadBindingUnavailableError(kind: SpawnBackendKind, mode: SpawnMode): string {
if (kind === "acp") {
return "thread=true for ACP sessions requires a channel context.";
}
if (mode === "session") {
return (
'sessions_spawn(mode="session") is only available on channels that expose thread bindings (e.g. Discord threads, Slack threads, Telegram forum topics). ' +
"This request is not running on a channel that can bind a subagent thread. " +
'Use mode="run" for one-shot subagent work, or sessions_send(sessionKey=...) to keep talking to a persistent session without thread binding.'
);
}
return (
"thread=true is only available on channels that expose thread bindings (e.g. Discord threads, Slack threads, Telegram forum topics). " +
"This request is not running on a channel that can bind a subagent thread. " +
"Retry without thread=true, or re-run sessions_spawn from a channel that supports threads."
);
}
export function prepareSpawnThreadBinding(params: {
cfg: OpenClawConfig;
kind: SpawnBackendKind;
mode: SpawnMode;
bindingService: SessionBindingService;
requesterSessionKey?: string;
channel?: string;
accountId?: string;
to?: string;
threadId?: string | number;
groupId?: string;
}): { ok: true; binding: PreparedSpawnThreadBinding } | { ok: false; error: string } {
const channel = normalizeOptionalLowercaseString(params.channel);
if (!channel) {
return { ok: false, error: buildThreadBindingUnavailableError(params.kind, params.mode) };
}
const accountId = resolveSpawnChannelAccountId({
cfg: params.cfg,
channel,
accountId: params.accountId,
});
const policy = resolveThreadBindingSpawnPolicy({
cfg: params.cfg,
channel,
accountId,
kind: params.kind,
});
if (!policy.enabled) {
return {
ok: false,
error: formatThreadBindingDisabledError({
channel: policy.channel,
accountId: policy.accountId,
kind: params.kind,
}),
};
}
if (!policy.spawnEnabled) {
return {
ok: false,
error: formatThreadBindingSpawnDisabledError({
channel: policy.channel,
accountId: policy.accountId,
kind: params.kind,
}),
};
}
const capabilities = params.bindingService.getCapabilities({
channel: policy.channel,
accountId: policy.accountId,
});
if (!capabilities.adapterAvailable) {
return {
ok: false,
error:
params.kind === "acp"
? `Thread bindings are unavailable for ${policy.channel}.`
: buildThreadBindingUnavailableError(params.kind, params.mode),
};
}
const placement =
resolveChannelDefaultBindingPlacement(policy.channel) ??
(capabilities.placements.includes("child") ? "child" : "current");
if (!capabilities.bindSupported || !capabilities.placements.includes(placement)) {
return {
ok: false,
error: `Thread bindings do not support ${placement} placement for ${policy.channel}.`,
};
}
const fallback = resolveConversationRefForThreadBinding({
cfg: params.cfg,
channel: policy.channel,
accountId: policy.accountId,
to: params.to,
threadId: params.threadId,
groupId: params.groupId,
});
const requesterConversation =
params.kind === "subagent"
? resolveRequesterBoundConversationRef({
bindingService: params.bindingService,
requesterSessionKey: params.requesterSessionKey,
channel: policy.channel,
accountId: policy.accountId,
fallback,
})
: undefined;
if (requesterConversation === null) {
return {
ok: false,
error: `Could not resolve a unique ${policy.channel} requester conversation for subagent thread spawn.`,
};
}
const conversation = requesterConversation ?? fallback;
if (!conversation?.conversationId) {
return {
ok: false,
error: `Could not resolve a ${policy.channel} conversation for ${params.kind} thread spawn.`,
};
}
return {
ok: true,
binding: {
channel: policy.channel,
accountId: policy.accountId,
placement,
conversationId: conversation.conversationId,
...(conversation.parentConversationId
? { parentConversationId: conversation.parentConversationId }
: {}),
},
};
}
export function resolveSpawnAdmission(params: {
cfg: OpenClawConfig;
enabled?: boolean;
collector?: {
liveChildren: number;
totalChildren: number;
maxChildrenPerGroup: number;
maxTotalPerGroup: number;
};
requesterSessionKey: string;
requesterAgentId: string;
targetAgentId: string;
requestedAgentId?: string;
configuredAgentIds: string[];
additionalActiveChildren?: number;
}):
| {
ok: true;
maxSpawnDepth?: number;
childSessionPatch?: {
spawnDepth: number;
subagentRole: "orchestrator" | "leaf" | null;
subagentControlScope: "children" | "none";
};
}
| { ok: false; governingCap?: ChildAdmissionCap; error: string } {
if (params.enabled === false) {
return { ok: true };
}
const callerDepth = getSubagentDepthFromSessionStore(params.requesterSessionKey, {
cfg: params.cfg,
});
const maxSpawnDepth =
params.cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
const collector = params.collector;
// Build each mode's params in its own branch so collector counts can never
// pair with the announce cap (or vice versa) through fallback chaining.
const childAdmission = collector
? resolveChildAdmission({
callerDepth,
maxSpawnDepth,
collect: true,
activeChildren: collector.liveChildren,
maxActiveChildren: collector.maxChildrenPerGroup,
totalChildren: collector.totalChildren,
maxTotalChildren: collector.maxTotalPerGroup,
})
: resolveChildAdmission({
callerDepth,
maxSpawnDepth,
collect: false,
activeChildren:
countActiveRunsForSession(params.requesterSessionKey, { collect: false }) +
(params.additionalActiveChildren ?? 0),
maxActiveChildren:
params.cfg.agents?.defaults?.subagents?.maxChildrenPerAgent ??
DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT,
});
if (!childAdmission.ok) {
return childAdmission;
}
const requesterSubagentConfig = resolveAgentConfig(
params.cfg,
params.requesterAgentId,
)?.subagents;
const requireAgentId =
requesterSubagentConfig?.requireAgentId ??
params.cfg.agents?.defaults?.subagents?.requireAgentId ??
false;
if (requireAgentId && !params.requestedAgentId?.trim()) {
return {
ok: false,
error:
"sessions_spawn requires explicit agentId when requireAgentId is configured. Use agents_list to see allowed agent ids.",
};
}
const targetPolicy = resolveSubagentTargetPolicy({
requesterAgentId: params.requesterAgentId,
targetAgentId: params.targetAgentId,
requestedAgentId: params.requestedAgentId,
allowAgents:
requesterSubagentConfig?.allowAgents ?? params.cfg.agents?.defaults?.subagents?.allowAgents,
configuredAgentIds: params.configuredAgentIds,
});
if (!targetPolicy.ok) {
return { ok: false, error: targetPolicy.error };
}
const capabilities = resolveSubagentCapabilities({
depth: callerDepth + 1,
maxSpawnDepth,
});
return {
ok: true,
maxSpawnDepth,
childSessionPatch: {
spawnDepth: capabilities.depth,
subagentRole: capabilities.role === "main" ? null : capabilities.role,
subagentControlScope: capabilities.controlScope,
},
};
}
export function resolveSpawnSandboxError(
params:
| {
backend: "acp";
requesterSandboxed: boolean;
sandbox: "inherit" | "require";
}
| {
backend: "subagent";
requesterSandboxed: boolean;
childSandboxed: boolean;
sandbox: "inherit" | "require";
},
): string | undefined {
if (params.backend === "acp") {
if (params.requesterSandboxed) {
return 'Sandboxed sessions cannot spawn ACP sessions because runtime="acp" runs on the host. Use runtime="subagent" from sandboxed sessions.';
}
return params.sandbox === "require"
? 'sessions_spawn sandbox="require" is unsupported for runtime="acp" because ACP sessions run outside the sandbox. Use runtime="subagent" or sandbox="inherit".'
: undefined;
}
if (params.childSandboxed || (!params.requesterSandboxed && params.sandbox !== "require")) {
return undefined;
}
return params.requesterSandboxed
? "Sandboxed sessions cannot spawn unsandboxed subagents. Set a sandboxed target agent or use the same agent runtime."
: 'sessions_spawn sandbox="require" needs a sandboxed target runtime. Pick a sandboxed agentId or use sandbox="inherit".';
}