openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
507 lines (506 loc) • 19.7 kB
JavaScript
import { i as formatErrorMessage } from "./errors-BXgSefBE.js";
import { t as createLazyImportLoader } from "./lazy-promise-BONnzNfb.js";
import { a as isSubagentSessionKey, c as parseAgentSessionKey } from "./session-key-utils-Bx3apsJ3.js";
import { r as logVerbose } from "./globals-GTrXU4s9.js";
import { o as callGateway } from "./call-B5-GYOlf.js";
import { t as INTERNAL_MESSAGE_CHANNEL } from "./message-channel-constants-CgI32lqD.js";
import { u as resolveStorePath } from "./paths-NEwU8m3X.js";
import { t as loadSessionStore } from "./store-load-C4uq6Lrq.js";
import { d as updateSessionStore } from "./store-Qsgtu-0y.js";
import "./message-channel-BiOeMu0l.js";
import { H as subagentRuns, t as getSubagentRunsSnapshotForRead } from "./subagent-registry-state-2yU6fwXx.js";
import { r as getLatestSubagentRunByChildSessionKey, s as listSubagentRunsForController } from "./subagent-registry-read-BmI9NJyA.js";
import { n as resolveStoredSubagentCapabilities } from "./subagent-capabilities-Cva3gXJL.js";
import { c as resolveMainSessionAlias, s as resolveInternalSessionKey } from "./sessions-helpers-hMXef98o.js";
import { t as AGENT_LANE_SUBAGENT } from "./lanes-CI0_P-yC.js";
import { a as waitForAgentRunAndReadUpdatedAssistantReply, r as readLatestAssistantReplySnapshot } from "./run-wait-Dy2KLsSJ.js";
import { _ as replaceSubagentRunAfterSteer, a as countPendingDescendantRuns, m as markSubagentRunTerminated, n as clearSubagentRunSteerRestart, p as markSubagentRunForSteerRestart } from "./subagent-registry-BeWX0MLH.js";
import { a as sortSubagentRuns, r as resolveSubagentLabel } from "./subagents-utils-BD_5s6t0.js";
import { r as resolveSessionEntryForKey, t as buildLatestSubagentRunIndex } from "./subagent-list-343gfNeV.js";
import crypto from "node:crypto";
//#region src/agents/subagent-control.ts
/**
* Implements subagent control operations: list, kill, steer, and send-message.
* The module enforces controller ownership before mutating child sessions or
* routing internal follow-up messages.
*/
/** Maximum recent-run window accepted by subagent control UI/tools. */
const MAX_RECENT_MINUTES = 1440;
const STEER_RATE_LIMIT_MS = 2e3;
const STEER_ABORT_SETTLE_TIMEOUT_MS = 5e3;
const SUBAGENT_REPLY_HISTORY_LIMIT = 50;
const steerRateLimit = /* @__PURE__ */ new Map();
let subagentControlDeps = {
callGateway,
updateSessionStore
};
const subagentControlRuntimeLoader = createLazyImportLoader(() => import("./subagent-control.runtime.js"));
function loadSubagentControlRuntime() {
return subagentControlRuntimeLoader.load();
}
async function resolveSubagentControlRuntime() {
if (subagentControlDeps.abortEmbeddedAgentRun && subagentControlDeps.clearSessionQueues) return {
abortEmbeddedAgentRun: subagentControlDeps.abortEmbeddedAgentRun,
clearSessionQueues: subagentControlDeps.clearSessionQueues
};
const runtime = await loadSubagentControlRuntime();
return {
abortEmbeddedAgentRun: subagentControlDeps.abortEmbeddedAgentRun ?? runtime.abortEmbeddedAgentRun,
clearSessionQueues: subagentControlDeps.clearSessionQueues ?? runtime.clearSessionQueues
};
}
/** Resolves which subagent runs the caller is allowed to control. */
function resolveSubagentController(params) {
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
const callerSessionKey = resolveInternalSessionKey({
key: params.agentSessionKey?.trim() || alias,
alias,
mainKey
});
if (!isSubagentSessionKey(callerSessionKey)) return {
controllerSessionKey: callerSessionKey,
callerSessionKey,
callerIsSubagent: false,
controlScope: "children"
};
return {
controllerSessionKey: callerSessionKey,
callerSessionKey,
callerIsSubagent: true,
controlScope: resolveStoredSubagentCapabilities(callerSessionKey, { cfg: params.cfg }).controlScope
};
}
/** Lists latest child runs controlled by a session key. */
function listControlledSubagentRuns(controllerSessionKey) {
const key = controllerSessionKey.trim();
if (!key) return [];
const latestByChildSessionKey = buildLatestSubagentRunIndex(getSubagentRunsSnapshotForRead(subagentRuns)).latestByChildSessionKey;
return sortSubagentRuns(Array.from(latestByChildSessionKey.values()).filter((entry) => {
return (entry.controllerSessionKey?.trim() || entry.requesterSessionKey?.trim()) === key;
}));
}
function ensureControllerOwnsRun(params) {
if ((params.entry.controllerSessionKey?.trim() || params.entry.requesterSessionKey) === params.controller.controllerSessionKey) return;
return "Subagents can only control runs spawned from their own session.";
}
function isFinishedForSteerControl(entry, hasPendingDescendants) {
return Boolean(entry.endedAt) && entry.pauseReason !== "sessions_yield" && !hasPendingDescendants;
}
async function killSubagentRun(params) {
if (params.entry.endedAt) return { killed: false };
const childSessionKey = params.entry.childSessionKey;
const resolved = resolveSessionEntryForKey({
cfg: params.cfg,
key: childSessionKey,
cache: params.cache
});
const sessionId = resolved.entry?.sessionId;
const runtime = await resolveSubagentControlRuntime();
const aborted = sessionId ? runtime.abortEmbeddedAgentRun(sessionId) : false;
const cleared = runtime.clearSessionQueues([childSessionKey, sessionId]);
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) logVerbose(`subagents control kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`);
if (resolved.entry) try {
await subagentControlDeps.updateSessionStore(resolved.storePath, (store) => {
const current = store[childSessionKey];
if (!current) return;
current.abortedLastRun = true;
current.updatedAt = Date.now();
store[childSessionKey] = current;
});
} catch (error) {
logVerbose(`subagents control kill: failed to persist abortedLastRun for ${childSessionKey}: ${formatErrorMessage(error)}`);
}
return {
killed: markSubagentRunTerminated({
runId: params.entry.runId,
childSessionKey,
reason: "killed"
}) > 0 || aborted || cleared.followupCleared > 0 || cleared.laneCleared > 0,
sessionId
};
}
async function cascadeKillChildren(params) {
const childRunsBySessionKey = /* @__PURE__ */ new Map();
for (const run of listSubagentRunsForController(params.parentChildSessionKey)) {
const childKey = run.childSessionKey?.trim();
if (!childKey) continue;
const latest = getLatestSubagentRunByChildSessionKey(childKey);
const latestControllerSessionKey = latest?.controllerSessionKey?.trim() || latest?.requesterSessionKey?.trim();
if (!latest || latest.runId !== run.runId || latestControllerSessionKey !== params.parentChildSessionKey) continue;
const existing = childRunsBySessionKey.get(childKey);
if (!existing || run.createdAt >= existing.createdAt) childRunsBySessionKey.set(childKey, run);
}
const childRuns = Array.from(childRunsBySessionKey.values());
const seenChildSessionKeys = params.seenChildSessionKeys ?? /* @__PURE__ */ new Set();
let killed = 0;
const labels = [];
for (const run of childRuns) {
const childKey = run.childSessionKey?.trim();
if (!childKey || seenChildSessionKeys.has(childKey)) continue;
seenChildSessionKeys.add(childKey);
if (!run.endedAt) {
if ((await killSubagentRun({
cfg: params.cfg,
entry: run,
cache: params.cache
})).killed) {
killed += 1;
labels.push(resolveSubagentLabel(run));
}
}
const cascade = await cascadeKillChildren({
cfg: params.cfg,
parentChildSessionKey: childKey,
cache: params.cache,
seenChildSessionKeys
});
killed += cascade.killed;
labels.push(...cascade.labels);
}
return {
killed,
labels
};
}
/** Kills every currently controlled child run and its descendants. */
async function killAllControlledSubagentRuns(params) {
if (params.controller.controlScope !== "children") return {
status: "forbidden",
error: "Leaf subagents cannot control other sessions.",
killed: 0,
labels: []
};
const cache = /* @__PURE__ */ new Map();
const seenChildSessionKeys = /* @__PURE__ */ new Set();
const killedLabels = [];
let killed = 0;
for (const entry of params.runs) {
const childKey = entry.childSessionKey?.trim();
if (!childKey || seenChildSessionKeys.has(childKey)) continue;
const currentEntry = getLatestSubagentRunByChildSessionKey(childKey);
if (!currentEntry || currentEntry.runId !== entry.runId) continue;
seenChildSessionKeys.add(childKey);
if (!currentEntry.endedAt) {
if ((await killSubagentRun({
cfg: params.cfg,
entry: currentEntry,
cache
})).killed) {
killed += 1;
killedLabels.push(resolveSubagentLabel(currentEntry));
}
}
const cascade = await cascadeKillChildren({
cfg: params.cfg,
parentChildSessionKey: childKey,
cache,
seenChildSessionKeys
});
killed += cascade.killed;
killedLabels.push(...cascade.labels);
}
return {
status: "ok",
killed,
labels: killedLabels
};
}
/** Kills one controlled subagent run and any active descendants. */
async function killControlledSubagentRun(params) {
const ownershipError = ensureControllerOwnsRun({
controller: params.controller,
entry: params.entry
});
if (ownershipError) return {
status: "forbidden",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: ownershipError
};
if (params.controller.controlScope !== "children") return {
status: "forbidden",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: "Leaf subagents cannot control other sessions."
};
const currentEntry = getLatestSubagentRunByChildSessionKey(params.entry.childSessionKey);
if (!currentEntry || currentEntry.runId !== params.entry.runId) return {
status: "done",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
label: resolveSubagentLabel(params.entry),
text: `${resolveSubagentLabel(params.entry)} is already finished.`
};
const killCache = /* @__PURE__ */ new Map();
const stopResult = await killSubagentRun({
cfg: params.cfg,
entry: currentEntry,
cache: killCache
});
const seenChildSessionKeys = /* @__PURE__ */ new Set();
const targetChildKey = params.entry.childSessionKey?.trim();
if (targetChildKey) seenChildSessionKeys.add(targetChildKey);
const cascade = await cascadeKillChildren({
cfg: params.cfg,
parentChildSessionKey: params.entry.childSessionKey,
cache: killCache,
seenChildSessionKeys
});
if (!stopResult.killed && cascade.killed === 0) return {
status: "done",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
label: resolveSubagentLabel(params.entry),
text: `${resolveSubagentLabel(params.entry)} is already finished.`
};
const cascadeText = cascade.killed > 0 ? ` (+ ${cascade.killed} descendant${cascade.killed === 1 ? "" : "s"})` : "";
return {
status: "ok",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
label: resolveSubagentLabel(params.entry),
cascadeKilled: cascade.killed,
cascadeLabels: cascade.killed > 0 ? cascade.labels : void 0,
text: stopResult.killed ? `killed ${resolveSubagentLabel(params.entry)}${cascadeText}.` : `killed ${cascade.killed} descendant${cascade.killed === 1 ? "" : "s"} of ${resolveSubagentLabel(params.entry)}.`
};
}
/** Admin kill path for a subagent session key, bypassing caller ownership checks. */
async function killSubagentRunAdmin(params) {
const targetSessionKey = params.sessionKey.trim();
if (!targetSessionKey) return {
found: false,
killed: false
};
const entry = getLatestSubagentRunByChildSessionKey(targetSessionKey);
if (!entry) return {
found: false,
killed: false
};
const killCache = /* @__PURE__ */ new Map();
const stopResult = await killSubagentRun({
cfg: params.cfg,
entry,
cache: killCache
});
const seenChildSessionKeys = new Set([targetSessionKey]);
const cascade = await cascadeKillChildren({
cfg: params.cfg,
parentChildSessionKey: targetSessionKey,
cache: killCache,
seenChildSessionKeys
});
return {
found: true,
killed: stopResult.killed || cascade.killed > 0,
runId: entry.runId,
sessionKey: entry.childSessionKey,
cascadeKilled: cascade.killed,
cascadeLabels: cascade.killed > 0 ? cascade.labels : void 0
};
}
/** Restarts a controlled subagent run with a new steering message. */
async function steerControlledSubagentRun(params) {
const ownershipError = ensureControllerOwnsRun({
controller: params.controller,
entry: params.entry
});
if (ownershipError) return {
status: "forbidden",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: ownershipError
};
if (params.controller.controlScope !== "children") return {
status: "forbidden",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: "Leaf subagents cannot control other sessions."
};
const targetHasPendingDescendants = countPendingDescendantRuns(params.entry.childSessionKey) > 0;
if (isFinishedForSteerControl(params.entry, targetHasPendingDescendants)) return {
status: "done",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
text: `${resolveSubagentLabel(params.entry)} is already finished.`
};
if (params.controller.callerSessionKey === params.entry.childSessionKey) return {
status: "forbidden",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: "Subagents cannot steer themselves."
};
const currentEntry = getLatestSubagentRunByChildSessionKey(params.entry.childSessionKey);
const currentHasPendingDescendants = currentEntry ? countPendingDescendantRuns(currentEntry.childSessionKey) > 0 : false;
if (!currentEntry || currentEntry.runId !== params.entry.runId || isFinishedForSteerControl(currentEntry, currentHasPendingDescendants)) return {
status: "done",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
text: `${resolveSubagentLabel(params.entry)} is already finished.`
};
const rateKey = `${params.controller.callerSessionKey}:${params.entry.childSessionKey}`;
if (process.env.VITEST !== "true") {
const now = Date.now();
if (now - (steerRateLimit.get(rateKey) ?? 0) < STEER_RATE_LIMIT_MS) return {
status: "rate_limited",
runId: params.entry.runId,
sessionKey: params.entry.childSessionKey,
error: "Steer rate limit exceeded. Wait a moment before sending another steer."
};
steerRateLimit.set(rateKey, now);
}
markSubagentRunForSteerRestart(params.entry.runId);
const targetSession = resolveSessionEntryForKey({
cfg: params.cfg,
key: params.entry.childSessionKey,
cache: /* @__PURE__ */ new Map()
});
const sessionId = typeof targetSession.entry?.sessionId === "string" && targetSession.entry.sessionId.trim() ? targetSession.entry.sessionId.trim() : void 0;
const restartSessionId = sessionId ? crypto.randomUUID() : void 0;
if (sessionId) (await resolveSubagentControlRuntime()).abortEmbeddedAgentRun(sessionId);
const cleared = (await resolveSubagentControlRuntime()).clearSessionQueues([params.entry.childSessionKey, sessionId]);
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) logVerbose(`subagents control steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`);
try {
await subagentControlDeps.callGateway({
method: "agent.wait",
params: {
runId: params.entry.runId,
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS
},
timeoutMs: 7e3
});
} catch {}
const idempotencyKey = crypto.randomUUID();
let runId = idempotencyKey;
try {
const response = await subagentControlDeps.callGateway({
method: "agent",
params: {
message: params.message,
sessionKey: params.entry.childSessionKey,
sessionId: restartSessionId,
idempotencyKey,
deliver: false,
channel: INTERNAL_MESSAGE_CHANNEL,
lane: AGENT_LANE_SUBAGENT,
timeout: 0
},
timeoutMs: 1e4
});
if (typeof response?.runId === "string" && response.runId) runId = response.runId;
} catch (err) {
clearSubagentRunSteerRestart(params.entry.runId);
const error = formatErrorMessage(err);
return {
status: "error",
runId,
sessionKey: params.entry.childSessionKey,
sessionId: restartSessionId,
error
};
}
if (!replaceSubagentRunAfterSteer({
previousRunId: params.entry.runId,
nextRunId: runId,
fallback: params.entry,
runTimeoutSeconds: params.entry.runTimeoutSeconds ?? 0
})) {
clearSubagentRunSteerRestart(params.entry.runId);
return {
status: "error",
runId,
sessionKey: params.entry.childSessionKey,
sessionId: restartSessionId,
error: "failed to replace steered subagent run"
};
}
return {
status: "accepted",
runId,
sessionKey: params.entry.childSessionKey,
sessionId: restartSessionId,
mode: "restart",
label: resolveSubagentLabel(params.entry),
text: `steered ${resolveSubagentLabel(params.entry)}.`
};
}
/** Sends a follow-up message to a controlled subagent and waits for a reply. */
async function sendControlledSubagentMessage(params) {
const ownershipError = ensureControllerOwnsRun({
controller: params.controller,
entry: params.entry
});
if (ownershipError) return {
status: "forbidden",
error: ownershipError
};
if (params.controller.controlScope !== "children") return {
status: "forbidden",
error: "Leaf subagents cannot control other sessions."
};
const currentEntry = getLatestSubagentRunByChildSessionKey(params.entry.childSessionKey);
if (!currentEntry || currentEntry.runId !== params.entry.runId) return {
status: "done",
runId: params.entry.runId,
text: `${resolveSubagentLabel(params.entry)} is already finished.`
};
const targetSessionKey = params.entry.childSessionKey;
const parsed = parseAgentSessionKey(targetSessionKey);
const targetSessionEntry = loadSessionStore(resolveStorePath(params.cfg.session?.store, { agentId: parsed?.agentId }))[targetSessionKey];
const targetSessionId = typeof targetSessionEntry?.sessionId === "string" && targetSessionEntry.sessionId.trim() ? targetSessionEntry.sessionId.trim() : void 0;
const idempotencyKey = crypto.randomUUID();
let runId = idempotencyKey;
try {
const baselineReply = await readLatestAssistantReplySnapshot({
sessionKey: targetSessionKey,
limit: SUBAGENT_REPLY_HISTORY_LIMIT,
callGateway: subagentControlDeps.callGateway
});
const response = await subagentControlDeps.callGateway({
method: "agent",
params: {
message: params.message,
sessionKey: targetSessionKey,
sessionId: targetSessionId,
idempotencyKey,
deliver: false,
channel: INTERNAL_MESSAGE_CHANNEL,
lane: AGENT_LANE_SUBAGENT,
timeout: 0
},
timeoutMs: 1e4
});
const responseRunId = typeof response?.runId === "string" ? response.runId : void 0;
if (responseRunId) runId = responseRunId;
const result = await waitForAgentRunAndReadUpdatedAssistantReply({
runId,
sessionKey: targetSessionKey,
timeoutMs: 3e4,
limit: SUBAGENT_REPLY_HISTORY_LIMIT,
baseline: baselineReply,
callGateway: subagentControlDeps.callGateway
});
if (result.status === "timeout") return {
status: "timeout",
runId
};
if (result.status === "error") return {
status: "error",
runId,
error: result.error ?? "unknown error"
};
return {
status: "ok",
runId,
replyText: result.replyText
};
} catch (err) {
const error = formatErrorMessage(err);
return {
status: "error",
runId,
error
};
}
}
//#endregion
export { listControlledSubagentRuns as a, steerControlledSubagentRun as c, killSubagentRunAdmin as i, killAllControlledSubagentRuns as n, resolveSubagentController as o, killControlledSubagentRun as r, sendControlledSubagentMessage as s, MAX_RECENT_MINUTES as t };