Files
openclaw/src/agents/tools/sessions-spawn-tool.ts
T

568 lines
21 KiB
TypeScript

/**
* sessions_spawn built-in tool.
*
* Starts subagent or ACP-backed sessions with inherited tool policy and delivery context.
*/
import { Type } from "typebox";
import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js";
import {
resolveThreadBindingSpawnPolicy,
supportsAutomaticThreadBindingSpawn,
} from "../../channels/thread-bindings-policy.js";
import { getRuntimeConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveSnakeCaseParamKey } from "../../param-key.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
import {
findAcpUnsupportedInheritedToolAllow,
findAcpUnsupportedInheritedToolDeny,
formatAcpInheritedToolAllowError,
formatAcpInheritedToolDenyError,
} from "../inherited-tool-deny.js";
import { optionalStringEnum } from "../schema/typebox.js";
import type { SpawnedToolContext } from "../spawned-context.js";
import { resolveAcpSessionsSpawnImageAttachments } from "../subagent-attachments.js";
import {
SUBAGENT_SPAWN_CONTEXT_MODES,
SUBAGENT_SPAWN_MODES,
spawnSubagentDirect,
} from "../subagent-spawn.js";
import { normalizeSubagentTaskName } from "../subagent-task-name.js";
import {
SWARM_CODE_MODE_IDEMPOTENCY_KEY,
SWARM_CODE_MODE_REQUEST_FINGERPRINT,
} from "../swarm-code-mode.js";
import { resolveSwarmConfig } from "../swarm-config.js";
import {
describeSessionsSpawnTool,
SESSIONS_SPAWN_SUBAGENT_TOOL_DISPLAY_SUMMARY,
SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY,
} from "../tool-description-presets.js";
import type { AnyAgentTool } from "./common.js";
import {
jsonResult,
normalizeToolModelOverride,
readNonNegativeIntegerParam,
readStringParam,
ToolInputError,
} from "./common.js";
import {
maybeSpawnVisibleSession,
type VisibleSessionsSpawnDeps,
VISIBLE_SESSIONS_SPAWN_SCHEMA,
} from "./sessions-spawn-visible.js";
const SESSIONS_SPAWN_RUNTIMES = ["subagent", "acp"] as const;
const SESSIONS_SPAWN_SANDBOX_MODES = ["inherit", "require"] as const;
// Keep the schema local to avoid a circular import through acp-spawn/openclaw-tools.
const SESSIONS_SPAWN_ACP_STREAM_TARGETS = ["parent"] as const;
const UNSUPPORTED_SESSIONS_SPAWN_PARAM_KEYS = [
"target",
"transport",
"channel",
"to",
"threadId",
"thread_id",
"replyTo",
"reply_to",
] as const;
type AcpSpawnModule = typeof import("../acp-spawn.js");
const acpSpawnModuleLoader = createLazyImportLoader<AcpSpawnModule>(
() => import("../acp-spawn.js"),
);
async function loadAcpSpawnModule(): Promise<AcpSpawnModule> {
return await acpSpawnModuleLoader.load();
}
function addRoleToFailureResult<T extends { status: string }>(
result: T,
role: string | undefined,
): T | (T & { role: string }) {
if (!role || (result.status !== "error" && result.status !== "forbidden")) {
return result;
}
return { ...result, role };
}
type SessionsSpawnThreadAvailability = {
subagent: boolean;
acp: boolean;
};
function hasAnyThreadAvailability(availability: SessionsSpawnThreadAvailability): boolean {
return availability.subagent || availability.acp;
}
function resolveSessionsSpawnThreadAvailability(opts?: {
config?: OpenClawConfig;
agentChannel?: GatewayMessageChannel;
agentAccountId?: string;
}): SessionsSpawnThreadAvailability {
const channel = opts?.agentChannel;
const cfg = opts?.config;
if (!channel || !cfg || !supportsAutomaticThreadBindingSpawn(channel)) {
return { subagent: false, acp: false };
}
const resolve = (kind: "subagent" | "acp") => {
const policy = resolveThreadBindingSpawnPolicy({
cfg,
channel,
accountId: opts?.agentAccountId,
kind,
});
return policy.enabled && policy.spawnEnabled;
};
return {
subagent: resolve("subagent"),
acp: resolve("acp"),
};
}
function createSessionsSpawnToolSchema(params: {
acpAvailable: boolean;
threadAvailable: boolean;
swarmEnabled: boolean;
}) {
const spawnModes = params.threadAvailable ? SUBAGENT_SPAWN_MODES : (["run"] as const);
const schema = {
task: Type.String(),
taskName: Type.Optional(
Type.String({
description:
"Stable later-target alias; starts lowercase letter; then lowercase/digit/_/-.",
}),
),
label: Type.Optional(
Type.String({
description: "Short task title shown in UI lists; name the work, not the agent.",
}),
),
runtime: optionalStringEnum(
params.acpAvailable ? SESSIONS_SPAWN_RUNTIMES : (["subagent"] as const),
{ description: 'Runtime; visible=true requires "subagent".' },
),
agentId: Type.Optional(Type.String()),
model: Type.Optional(Type.String()),
runTimeoutSeconds: Type.Optional(
Type.Integer({
minimum: 0,
description:
"Per-run timeout in seconds; overrides the configured subagent default. Zero disables the timeout.",
}),
),
thinking: Type.Optional(
Type.String({ description: "Thinking override; unavailable with visible=true." }),
),
cwd: Type.Optional(Type.String()),
...(params.threadAvailable
? {
thread: Type.Optional(
Type.Boolean({
description:
'Bind new chat thread when supported; true defaults mode="session"; unavailable with visible=true.',
}),
),
}
: {}),
mode: optionalStringEnum(spawnModes, {
description: params.threadAvailable
? '"run" one-shot; "session" persistent/thread-bound. Omit with visible=true.'
: '"run" one-shot. Omit with visible=true; visible sessions are persistent.',
}),
cleanup: optionalStringEnum(["delete", "keep"] as const, {
description: "Hidden session cleanup; visible=true always keeps the session.",
}),
sandbox: optionalStringEnum(SESSIONS_SPAWN_SANDBOX_MODES, {
description: '"inherit" parent sandbox policy; "require" fails unless child is sandboxed.',
}),
context: optionalStringEnum(SUBAGENT_SPAWN_CONTEXT_MODES, {
description:
"Native: omit/isolated clean; fork only needing requester transcript; visible fork requires same agent.",
}),
lightContext: Type.Optional(
Type.Boolean({
description: "Light bootstrap; subagent only; unavailable with visible=true.",
}),
),
...(params.swarmEnabled
? {
collect: Type.Optional(
Type.Boolean({
description: "Swarm collector child for parallel fan-out; await via agents_wait.",
}),
),
outputSchema: Type.Optional(
Type.Record(Type.String(), Type.Unknown(), {
description: "JSON Schema for the child's structured result; requires collect=true.",
}),
),
fastMode: Type.Optional(Type.Union([Type.Boolean(), Type.Literal("auto")])),
groupId: Type.Optional(
Type.String({
description: "Groups parallel collector children; requires collect=true.",
}),
),
}
: {}),
...VISIBLE_SESSIONS_SPAWN_SCHEMA,
// Inline attachments (snapshot-by-value).
attachments: Type.Optional(
Type.Array(
Type.Object({
name: Type.String(),
content: Type.String(),
encoding: Type.Optional(optionalStringEnum(["utf8", "base64"] as const)),
mimeType: Type.Optional(Type.String()),
}),
{ maxItems: 50, description: "Inline snapshots; unavailable with visible=true." },
),
),
attachAs: Type.Optional(
Type.Object(
{
// Where the spawned agent should look for attachments.
// Kept as a hint; implementation materializes into the child workspace.
mountPath: Type.Optional(Type.String()),
},
{ description: "Attachment mount hint; unavailable with visible=true." },
),
),
...(params.acpAvailable
? {
resumeSessionId: Type.Optional(
Type.String({
description: "ACP resume id already recorded for requester; ignored by subagent.",
}),
),
streamTo: optionalStringEnum(SESSIONS_SPAWN_ACP_STREAM_TARGETS, {
description: 'ACP only; "parent" streams turn to requester. Ignored by subagent.',
}),
}
: {}),
};
return Type.Object(schema);
}
function resolveAcpUnavailableMessage(opts?: { sandboxed?: boolean; config?: OpenClawConfig }) {
if (opts?.sandboxed === true) {
return 'runtime="acp" is unavailable from sandboxed sessions because ACP sessions run on the host. Use runtime="subagent".';
}
if (opts?.config?.acp?.enabled === false) {
return 'runtime="acp" is unavailable because ACP is disabled by policy (`acp.enabled=false`). Use runtime="subagent".';
}
return 'runtime="acp" is unavailable in this session because no ACP runtime backend is loaded. Enable the acpx plugin or use runtime="subagent".';
}
export function createSessionsSpawnTool(
opts?: {
agentSessionKey?: string;
requesterTurnRunId?: string;
/** Separate key used only for completion routing (registerSubagentRun requesterSessionKey). */
completionOwnerKey?: string;
agentChannel?: GatewayMessageChannel;
agentAccountId?: string;
agentTo?: string;
agentThreadId?: string | number;
currentMessagingTarget?: string;
currentChannelId?: string;
currentThreadTs?: string;
currentMessageId?: string | number;
sandboxed?: boolean;
config?: OpenClawConfig;
/** Explicit agent ID override for cron/hook sessions where session key parsing may not work. */
requesterAgentIdOverride?: string;
requesterRunId?: string;
swarmCollector?: boolean;
} & VisibleSessionsSpawnDeps &
SpawnedToolContext,
): AnyAgentTool {
const acpAvailable = isAcpRuntimeSpawnAvailable({
config: opts?.config,
sandboxed: opts?.sandboxed,
});
const threadAvailability = resolveSessionsSpawnThreadAvailability(opts);
const threadAvailable = hasAnyThreadAvailability(threadAvailability);
const requesterAgentId =
opts?.requesterAgentIdOverride ?? parseAgentSessionKey(opts?.agentSessionKey)?.agentId;
const swarmConfig = resolveSwarmConfig(opts?.config, requesterAgentId);
return {
label: "Sessions",
name: "sessions_spawn",
displaySummary: acpAvailable
? SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY
: SESSIONS_SPAWN_SUBAGENT_TOOL_DISPLAY_SUMMARY,
description: describeSessionsSpawnTool({
acpAvailable,
threadAvailable,
swarmEnabled: swarmConfig.enabled,
}),
parameters: createSessionsSpawnToolSchema({
acpAvailable,
threadAvailable,
swarmEnabled: swarmConfig.enabled,
}),
execute: async (_toolCallId, args) => {
const params = args as Record<PropertyKey, unknown>;
if (opts?.swarmCollector && params.collect !== true) {
throw new ToolInputError(
"sessions_spawn from a collector requires collect=true so approvals stay non-interactive.",
);
}
const swarmParam = ["collect", "outputSchema", "fastMode", "groupId"].find((key) =>
Object.hasOwn(params, key),
);
if (swarmParam && !swarmConfig.enabled) {
throw new ToolInputError(
`sessions_spawn parameter "${swarmParam}" requires tools.swarm.enabled=true.`,
);
}
const hasCollectParam = Object.hasOwn(params, "collect");
const collect = params.collect === true;
if (params.outputSchema !== undefined && !collect) {
throw new ToolInputError('sessions_spawn "outputSchema" requires collect=true.');
}
if (params.groupId !== undefined && !collect) {
throw new ToolInputError('sessions_spawn "groupId" requires collect=true.');
}
if (
collect &&
(params.thread === true || params.visible === true || params.mode === "session")
) {
throw new ToolInputError(
"sessions_spawn collect=true does not support thread, visible, or session mode.",
);
}
const unsupportedParam = UNSUPPORTED_SESSIONS_SPAWN_PARAM_KEYS.find((key) =>
Object.hasOwn(params, key),
);
if (unsupportedParam) {
throw new ToolInputError(
`sessions_spawn does not support "${unsupportedParam}". Use "message" or "sessions_send" for channel delivery.`,
);
}
const unsupportedTimeoutParam = resolveSnakeCaseParamKey(params, "timeoutSeconds");
if (unsupportedTimeoutParam) {
throw new ToolInputError(
`sessions_spawn does not support "${unsupportedTimeoutParam}". Use "runTimeoutSeconds" for a per-run timeout.`,
);
}
const task = readStringParam(params, "task", { required: true });
const runTimeoutSeconds = readNonNegativeIntegerParam(params, "runTimeoutSeconds");
const taskNameResult = normalizeSubagentTaskName(params.taskName);
if (taskNameResult.error) {
return jsonResult({
status: "error",
error: taskNameResult.error,
});
}
const taskName = taskNameResult.taskName;
const label = readStringParam(params, "label") ?? "";
const runtime = params.runtime === "acp" ? "acp" : "subagent";
if (collect && runtime === "acp") {
throw new ToolInputError('sessions_spawn collect=true supports runtime="subagent" only.');
}
const requestedAgentId = readStringParam(params, "agentId");
const resumeSessionId = readStringParam(params, "resumeSessionId");
const modelOverride = normalizeToolModelOverride(readStringParam(params, "model"));
const thinkingOverrideRaw = readStringParam(params, "thinking");
const cwd = readStringParam(params, "cwd");
const mode = params.mode === "run" || params.mode === "session" ? params.mode : undefined;
const cleanup =
params.cleanup === "keep" || params.cleanup === "delete" ? params.cleanup : "keep";
const expectsCompletionMessage = collect ? false : params.expectsCompletionMessage !== false;
const sandbox = params.sandbox === "require" ? "require" : "inherit";
const context =
params.context === "fork" || params.context === "isolated" ? params.context : undefined;
const streamTo = runtime === "acp" && params.streamTo === "parent" ? "parent" : undefined;
const lightContext = params.lightContext === true;
const roleContext = requestedAgentId ? { role: requestedAgentId } : {};
const visibleResult = await maybeSpawnVisibleSession({
raw: params,
task,
taskName,
label,
runtime,
requestedAgentId,
runTimeoutSeconds,
sandbox,
options: opts,
});
if (visibleResult) {
return jsonResult(
addRoleToFailureResult(visibleResult as { status: string }, requestedAgentId),
);
}
if (runtime === "acp" && !acpAvailable) {
return jsonResult({
status: "error",
error: resolveAcpUnavailableMessage(opts),
...roleContext,
});
}
const acpUnsupportedInheritedTool =
runtime === "acp"
? findAcpUnsupportedInheritedToolDeny(opts?.inheritedToolDenylist)
: undefined;
if (acpUnsupportedInheritedTool) {
return jsonResult({
status: "forbidden",
error: formatAcpInheritedToolDenyError(acpUnsupportedInheritedTool),
...roleContext,
});
}
const acpUnsupportedInheritedAllow =
runtime === "acp"
? findAcpUnsupportedInheritedToolAllow(opts?.inheritedToolAllowlist)
: undefined;
if (acpUnsupportedInheritedAllow) {
return jsonResult({
status: "forbidden",
error: formatAcpInheritedToolAllowError(acpUnsupportedInheritedAllow),
...roleContext,
});
}
if (runtime === "acp" && lightContext) {
throw new Error("lightContext is only supported for runtime='subagent'.");
}
if (runtime === "acp" && context === "fork") {
throw new Error('context="fork" is only supported for runtime="subagent".');
}
const thread = params.thread === true;
const attachments = Array.isArray(params.attachments)
? (params.attachments as Array<{
name: string;
content: string;
encoding?: "utf8" | "base64";
mimeType?: string;
}>)
: undefined;
if (runtime === "acp") {
const { spawnAcpDirect } = await loadAcpSpawnModule();
const acpAttachments = resolveAcpSessionsSpawnImageAttachments({
config: opts?.config ?? getRuntimeConfig(),
attachments,
});
if (acpAttachments?.status === "forbidden" || acpAttachments?.status === "error") {
return jsonResult({
status: acpAttachments.status,
error: acpAttachments.error,
...roleContext,
});
}
const result = await spawnAcpDirect(
{
task,
taskName,
label: label || undefined,
agentId: requestedAgentId,
resumeSessionId,
model: modelOverride,
thinking: thinkingOverrideRaw,
...(runTimeoutSeconds !== undefined ? { runTimeoutSeconds } : {}),
cwd,
mode: mode === "run" || mode === "session" ? mode : undefined,
thread,
sandbox,
cleanup,
expectsCompletionMessage,
streamTo,
attachments: acpAttachments?.attachments,
},
{
agentSessionKey: opts?.agentSessionKey,
requesterTurnRunId: opts?.requesterTurnRunId,
completionOwnerKey: opts?.completionOwnerKey,
requesterAgentIdOverride: opts?.requesterAgentIdOverride,
agentChannel: opts?.agentChannel,
agentAccountId: opts?.agentAccountId,
agentTo: opts?.agentTo,
agentThreadId: opts?.agentThreadId,
currentMessagingTarget: opts?.currentMessagingTarget,
currentChannelId: opts?.currentChannelId,
currentMessageId: opts?.currentMessageId,
agentGroupId: opts?.agentGroupId ?? undefined,
agentGroupSpace: opts?.agentGroupSpace,
agentMemberRoleIds: opts?.agentMemberRoleIds,
sandboxed: opts?.sandboxed,
inheritedToolAllowlist: opts?.inheritedToolAllowlist,
inheritedToolDenylist: opts?.inheritedToolDenylist,
},
);
return jsonResult(addRoleToFailureResult(result, requestedAgentId));
}
const result = await spawnSubagentDirect(
{
task,
taskName,
label: label || undefined,
agentId: requestedAgentId,
model: modelOverride,
thinking: thinkingOverrideRaw,
...(runTimeoutSeconds !== undefined ? { runTimeoutSeconds } : {}),
collect: hasCollectParam ? collect : undefined,
outputSchema:
params.outputSchema && typeof params.outputSchema === "object"
? (params.outputSchema as Record<string, unknown>)
: undefined,
fastMode:
params.fastMode === true || params.fastMode === false || params.fastMode === "auto"
? params.fastMode
: undefined,
groupId: readStringParam(params, "groupId"),
swarmLaunchReplayKey:
typeof params[SWARM_CODE_MODE_IDEMPOTENCY_KEY] === "string"
? params[SWARM_CODE_MODE_IDEMPOTENCY_KEY]
: undefined,
swarmLaunchRequestFingerprint:
typeof params[SWARM_CODE_MODE_REQUEST_FINGERPRINT] === "string"
? params[SWARM_CODE_MODE_REQUEST_FINGERPRINT]
: undefined,
cwd,
thread,
mode,
cleanup,
sandbox,
context,
lightContext,
expectsCompletionMessage,
attachments,
attachMountPath:
params.attachAs && typeof params.attachAs === "object"
? readStringParam(params.attachAs as Record<string, unknown>, "mountPath")
: undefined,
},
{
agentSessionKey: opts?.agentSessionKey,
requesterTurnRunId: opts?.requesterTurnRunId,
completionOwnerKey: opts?.completionOwnerKey,
agentChannel: opts?.agentChannel,
agentAccountId: opts?.agentAccountId,
agentTo: opts?.agentTo,
agentThreadId: opts?.agentThreadId,
currentMessagingTarget: opts?.currentMessagingTarget ?? opts?.currentChannelId,
currentChannelId: opts?.currentChannelId,
currentMessageId: opts?.currentMessageId,
agentGroupId: opts?.agentGroupId,
agentGroupChannel: opts?.agentGroupChannel,
agentGroupSpace: opts?.agentGroupSpace,
agentMemberRoleIds: opts?.agentMemberRoleIds,
requesterAgentIdOverride: opts?.requesterAgentIdOverride,
workspaceDir: opts?.workspaceDir,
inheritedToolAllowlist: opts?.inheritedToolAllowlist,
inheritedToolDenylist: opts?.inheritedToolDenylist,
requesterRunId: opts?.requesterRunId,
},
);
return jsonResult(addRoleToFailureResult(result, requestedAgentId));
},
};
}