Files
openclaw/src/auto-reply/reply/commands-subagents.ts
2026-02-17 09:18:40 +09:00

702 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import crypto from "node:crypto";
import { AGENT_LANE_SUBAGENT } from "../../agents/lanes.js";
import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js";
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
import {
clearSubagentRunSteerRestart,
listSubagentRunsForRequester,
markSubagentRunTerminated,
markSubagentRunForSteerRestart,
replaceSubagentRunAfterSteer,
} from "../../agents/subagent-registry.js";
import { spawnSubagentDirect } from "../../agents/subagent-spawn.js";
import {
extractAssistantText,
resolveInternalSessionKey,
resolveMainSessionAlias,
sanitizeTextContent,
stripToolMessages,
} from "../../agents/tools/sessions-helpers.js";
import {
type SessionEntry,
loadSessionStore,
resolveStorePath,
updateSessionStore,
} from "../../config/sessions.js";
import { callGateway } from "../../gateway/call.js";
import { logVerbose } from "../../globals.js";
import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { extractTextFromChatContent } from "../../shared/chat-content.js";
import {
formatDurationCompact,
formatTokenUsageDisplay,
truncateLine,
} from "../../shared/subagents-format.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import { stopSubagentsForRequester } from "./abort.js";
import type { CommandHandler } from "./commands-types.js";
import { clearSessionQueues } from "./queue.js";
import { formatRunLabel, formatRunStatus, sortSubagentRuns } from "./subagents-utils.js";
type SubagentTargetResolution = {
entry?: SubagentRunRecord;
error?: string;
};
const COMMAND = "/subagents";
const COMMAND_KILL = "/kill";
const COMMAND_STEER = "/steer";
const COMMAND_TELL = "/tell";
const ACTIONS = new Set(["list", "kill", "log", "send", "steer", "info", "spawn", "help"]);
const RECENT_WINDOW_MINUTES = 30;
const SUBAGENT_TASK_PREVIEW_MAX = 110;
const STEER_ABORT_SETTLE_TIMEOUT_MS = 5_000;
function compactLine(value: string) {
return value.replace(/\s+/g, " ").trim();
}
function formatTaskPreview(value: string) {
return truncateLine(compactLine(value), SUBAGENT_TASK_PREVIEW_MAX);
}
function resolveModelDisplay(
entry?: {
model?: unknown;
modelProvider?: unknown;
modelOverride?: unknown;
providerOverride?: unknown;
},
fallbackModel?: string,
) {
const model = typeof entry?.model === "string" ? entry.model.trim() : "";
const provider = typeof entry?.modelProvider === "string" ? entry.modelProvider.trim() : "";
let combined = model.includes("/") ? model : model && provider ? `${provider}/${model}` : model;
if (!combined) {
// Fall back to override fields which are populated at spawn time,
// before the first run completes and writes model/modelProvider.
const overrideModel =
typeof entry?.modelOverride === "string" ? entry.modelOverride.trim() : "";
const overrideProvider =
typeof entry?.providerOverride === "string" ? entry.providerOverride.trim() : "";
combined = overrideModel.includes("/")
? overrideModel
: overrideModel && overrideProvider
? `${overrideProvider}/${overrideModel}`
: overrideModel;
}
if (!combined) {
combined = fallbackModel?.trim() || "";
}
if (!combined) {
return "model n/a";
}
const slash = combined.lastIndexOf("/");
if (slash >= 0 && slash < combined.length - 1) {
return combined.slice(slash + 1);
}
return combined;
}
function resolveDisplayStatus(entry: SubagentRunRecord) {
const status = formatRunStatus(entry);
return status === "error" ? "failed" : status;
}
function formatTimestamp(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
return "n/a";
}
return new Date(valueMs).toISOString();
}
function formatTimestampWithAge(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
return "n/a";
}
return `${formatTimestamp(valueMs)} (${formatTimeAgo(Date.now() - valueMs, { fallback: "n/a" })})`;
}
function resolveRequesterSessionKey(params: Parameters<CommandHandler>[0]): string | undefined {
const raw = params.sessionKey?.trim() || params.ctx.CommandTargetSessionKey?.trim();
if (!raw) {
return undefined;
}
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
return resolveInternalSessionKey({ key: raw, alias, mainKey });
}
function resolveSubagentTarget(
runs: SubagentRunRecord[],
token: string | undefined,
): SubagentTargetResolution {
const trimmed = token?.trim();
if (!trimmed) {
return { error: "Missing subagent id." };
}
if (trimmed === "last") {
const sorted = sortSubagentRuns(runs);
return { entry: sorted[0] };
}
const sorted = sortSubagentRuns(runs);
const recentCutoff = Date.now() - RECENT_WINDOW_MINUTES * 60_000;
const numericOrder = [
...sorted.filter((entry) => !entry.endedAt),
...sorted.filter((entry) => !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff),
];
if (/^\d+$/.test(trimmed)) {
const idx = Number.parseInt(trimmed, 10);
if (!Number.isFinite(idx) || idx <= 0 || idx > numericOrder.length) {
return { error: `Invalid subagent index: ${trimmed}` };
}
return { entry: numericOrder[idx - 1] };
}
if (trimmed.includes(":")) {
const match = runs.find((entry) => entry.childSessionKey === trimmed);
return match ? { entry: match } : { error: `Unknown subagent session: ${trimmed}` };
}
const lowered = trimmed.toLowerCase();
const byLabel = runs.filter((entry) => formatRunLabel(entry).toLowerCase() === lowered);
if (byLabel.length === 1) {
return { entry: byLabel[0] };
}
if (byLabel.length > 1) {
return { error: `Ambiguous subagent label: ${trimmed}` };
}
const byLabelPrefix = runs.filter((entry) =>
formatRunLabel(entry).toLowerCase().startsWith(lowered),
);
if (byLabelPrefix.length === 1) {
return { entry: byLabelPrefix[0] };
}
if (byLabelPrefix.length > 1) {
return { error: `Ambiguous subagent label prefix: ${trimmed}` };
}
const byRunId = runs.filter((entry) => entry.runId.startsWith(trimmed));
if (byRunId.length === 1) {
return { entry: byRunId[0] };
}
if (byRunId.length > 1) {
return { error: `Ambiguous run id prefix: ${trimmed}` };
}
return { error: `Unknown subagent id: ${trimmed}` };
}
function buildSubagentsHelp() {
return [
"Subagents",
"Usage:",
"- /subagents list",
"- /subagents kill <id|#|all>",
"- /subagents log <id|#> [limit] [tools]",
"- /subagents info <id|#>",
"- /subagents send <id|#> <message>",
"- /subagents steer <id|#> <message>",
"- /subagents spawn <agentId> <task> [--model <model>] [--thinking <level>]",
"- /kill <id|#|all>",
"- /steer <id|#> <message>",
"- /tell <id|#> <message>",
"",
"Ids: use the list index (#), runId/session prefix, label, or full session key.",
].join("\n");
}
type ChatMessage = {
role?: unknown;
content?: unknown;
};
export function extractMessageText(message: ChatMessage): { role: string; text: string } | null {
const role = typeof message.role === "string" ? message.role : "";
const shouldSanitize = role === "assistant";
const text = extractTextFromChatContent(message.content, {
sanitizeText: shouldSanitize ? sanitizeTextContent : undefined,
});
return text ? { role, text } : null;
}
function formatLogLines(messages: ChatMessage[]) {
const lines: string[] = [];
for (const msg of messages) {
const extracted = extractMessageText(msg);
if (!extracted) {
continue;
}
const label = extracted.role === "assistant" ? "Assistant" : "User";
lines.push(`${label}: ${extracted.text}`);
}
return lines;
}
type SessionStoreCache = Map<string, Record<string, SessionEntry>>;
function loadSubagentSessionEntry(
params: Parameters<CommandHandler>[0],
childKey: string,
storeCache?: SessionStoreCache,
) {
const parsed = parseAgentSessionKey(childKey);
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed?.agentId });
let store = storeCache?.get(storePath);
if (!store) {
store = loadSessionStore(storePath);
storeCache?.set(storePath, store);
}
return { storePath, store, entry: store[childKey] };
}
export const handleSubagentsCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
}
const normalized = params.command.commandBodyNormalized;
const handledPrefix = normalized.startsWith(COMMAND)
? COMMAND
: normalized.startsWith(COMMAND_KILL)
? COMMAND_KILL
: normalized.startsWith(COMMAND_STEER)
? COMMAND_STEER
: normalized.startsWith(COMMAND_TELL)
? COMMAND_TELL
: null;
if (!handledPrefix) {
return null;
}
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring ${handledPrefix} from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
const rest = normalized.slice(handledPrefix.length).trim();
const restTokens = rest.split(/\s+/).filter(Boolean);
let action = "list";
if (handledPrefix === COMMAND) {
const [actionRaw] = restTokens;
action = actionRaw?.toLowerCase() || "list";
if (!ACTIONS.has(action)) {
return { shouldContinue: false, reply: { text: buildSubagentsHelp() } };
}
restTokens.splice(0, 1);
} else if (handledPrefix === COMMAND_KILL) {
action = "kill";
} else {
action = "steer";
}
const requesterKey = resolveRequesterSessionKey(params);
if (!requesterKey) {
return { shouldContinue: false, reply: { text: "⚠️ Missing session key." } };
}
const runs = listSubagentRunsForRequester(requesterKey);
if (action === "help") {
return { shouldContinue: false, reply: { text: buildSubagentsHelp() } };
}
if (action === "list") {
const sorted = sortSubagentRuns(runs);
const now = Date.now();
const recentCutoff = now - RECENT_WINDOW_MINUTES * 60_000;
const storeCache: SessionStoreCache = new Map();
let index = 1;
const activeLines = sorted
.filter((entry) => !entry.endedAt)
.map((entry) => {
const { entry: sessionEntry } = loadSubagentSessionEntry(
params,
entry.childSessionKey,
storeCache,
);
const usageText = formatTokenUsageDisplay(sessionEntry);
const label = truncateLine(formatRunLabel(entry, { maxLength: 48 }), 48);
const task = formatTaskPreview(entry.task);
const runtime = formatDurationCompact(now - (entry.startedAt ?? entry.createdAt));
const status = resolveDisplayStatus(entry);
const line = `${index}. ${label} (${resolveModelDisplay(sessionEntry, entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`;
index += 1;
return line;
});
const recentLines = sorted
.filter((entry) => !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff)
.map((entry) => {
const { entry: sessionEntry } = loadSubagentSessionEntry(
params,
entry.childSessionKey,
storeCache,
);
const usageText = formatTokenUsageDisplay(sessionEntry);
const label = truncateLine(formatRunLabel(entry, { maxLength: 48 }), 48);
const task = formatTaskPreview(entry.task);
const runtime = formatDurationCompact(
(entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt),
);
const status = resolveDisplayStatus(entry);
const line = `${index}. ${label} (${resolveModelDisplay(sessionEntry, entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`;
index += 1;
return line;
});
const lines = ["active subagents:", "-----"];
if (activeLines.length === 0) {
lines.push("(none)");
} else {
lines.push(activeLines.join("\n"));
}
lines.push("", `recent subagents (last ${RECENT_WINDOW_MINUTES}m):`, "-----");
if (recentLines.length === 0) {
lines.push("(none)");
} else {
lines.push(recentLines.join("\n"));
}
return { shouldContinue: false, reply: { text: lines.join("\n") } };
}
if (action === "kill") {
const target = restTokens[0];
if (!target) {
return {
shouldContinue: false,
reply: {
text:
handledPrefix === COMMAND
? "Usage: /subagents kill <id|#|all>"
: "Usage: /kill <id|#|all>",
},
};
}
if (target === "all" || target === "*") {
stopSubagentsForRequester({
cfg: params.cfg,
requesterSessionKey: requesterKey,
});
return { shouldContinue: false };
}
const resolved = resolveSubagentTarget(runs, target);
if (!resolved.entry) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` },
};
}
if (resolved.entry.endedAt) {
return {
shouldContinue: false,
reply: { text: `${formatRunLabel(resolved.entry)} is already finished.` },
};
}
const childKey = resolved.entry.childSessionKey;
const { storePath, store, entry } = loadSubagentSessionEntry(params, childKey);
const sessionId = entry?.sessionId;
if (sessionId) {
abortEmbeddedPiRun(sessionId);
}
const cleared = clearSessionQueues([childKey, sessionId]);
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
logVerbose(
`subagents kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
);
}
if (entry) {
entry.abortedLastRun = true;
entry.updatedAt = Date.now();
store[childKey] = entry;
await updateSessionStore(storePath, (nextStore) => {
nextStore[childKey] = entry;
});
}
markSubagentRunTerminated({
runId: resolved.entry.runId,
childSessionKey: childKey,
reason: "killed",
});
// Cascade: also stop any sub-sub-agents spawned by this child.
stopSubagentsForRequester({
cfg: params.cfg,
requesterSessionKey: childKey,
});
return { shouldContinue: false };
}
if (action === "info") {
const target = restTokens[0];
if (!target) {
return { shouldContinue: false, reply: { text: " Usage: /subagents info <id|#>" } };
}
const resolved = resolveSubagentTarget(runs, target);
if (!resolved.entry) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` },
};
}
const run = resolved.entry;
const { entry: sessionEntry } = loadSubagentSessionEntry(params, run.childSessionKey);
const runtime =
run.startedAt && Number.isFinite(run.startedAt)
? (formatDurationCompact((run.endedAt ?? Date.now()) - run.startedAt) ?? "n/a")
: "n/a";
const outcome = run.outcome
? `${run.outcome.status}${run.outcome.error ? ` (${run.outcome.error})` : ""}`
: "n/a";
const lines = [
" Subagent info",
`Status: ${resolveDisplayStatus(run)}`,
`Label: ${formatRunLabel(run)}`,
`Task: ${run.task}`,
`Run: ${run.runId}`,
`Session: ${run.childSessionKey}`,
`SessionId: ${sessionEntry?.sessionId ?? "n/a"}`,
`Transcript: ${sessionEntry?.sessionFile ?? "n/a"}`,
`Runtime: ${runtime}`,
`Created: ${formatTimestampWithAge(run.createdAt)}`,
`Started: ${formatTimestampWithAge(run.startedAt)}`,
`Ended: ${formatTimestampWithAge(run.endedAt)}`,
`Cleanup: ${run.cleanup}`,
run.archiveAtMs ? `Archive: ${formatTimestampWithAge(run.archiveAtMs)}` : undefined,
run.cleanupHandled ? "Cleanup handled: yes" : undefined,
`Outcome: ${outcome}`,
].filter(Boolean);
return { shouldContinue: false, reply: { text: lines.join("\n") } };
}
if (action === "log") {
const target = restTokens[0];
if (!target) {
return { shouldContinue: false, reply: { text: "📜 Usage: /subagents log <id|#> [limit]" } };
}
const includeTools = restTokens.some((token) => token.toLowerCase() === "tools");
const limitToken = restTokens.find((token) => /^\d+$/.test(token));
const limit = limitToken ? Math.min(200, Math.max(1, Number.parseInt(limitToken, 10))) : 20;
const resolved = resolveSubagentTarget(runs, target);
if (!resolved.entry) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` },
};
}
const history = await callGateway<{ messages: Array<unknown> }>({
method: "chat.history",
params: { sessionKey: resolved.entry.childSessionKey, limit },
});
const rawMessages = Array.isArray(history?.messages) ? history.messages : [];
const filtered = includeTools ? rawMessages : stripToolMessages(rawMessages);
const lines = formatLogLines(filtered as ChatMessage[]);
const header = `📜 Subagent log: ${formatRunLabel(resolved.entry)}`;
if (lines.length === 0) {
return { shouldContinue: false, reply: { text: `${header}\n(no messages)` } };
}
return { shouldContinue: false, reply: { text: [header, ...lines].join("\n") } };
}
if (action === "send" || action === "steer") {
const steerRequested = action === "steer";
const target = restTokens[0];
const message = restTokens.slice(1).join(" ").trim();
if (!target || !message) {
return {
shouldContinue: false,
reply: {
text: steerRequested
? handledPrefix === COMMAND
? "Usage: /subagents steer <id|#> <message>"
: `Usage: ${handledPrefix} <id|#> <message>`
: "Usage: /subagents send <id|#> <message>",
},
};
}
const resolved = resolveSubagentTarget(runs, target);
if (!resolved.entry) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` },
};
}
if (steerRequested && resolved.entry.endedAt) {
return {
shouldContinue: false,
reply: { text: `${formatRunLabel(resolved.entry)} is already finished.` },
};
}
const { entry: targetSessionEntry } = loadSubagentSessionEntry(
params,
resolved.entry.childSessionKey,
);
const targetSessionId =
typeof targetSessionEntry?.sessionId === "string" && targetSessionEntry.sessionId.trim()
? targetSessionEntry.sessionId.trim()
: undefined;
if (steerRequested) {
// Suppress stale announce before interrupting the in-flight run.
markSubagentRunForSteerRestart(resolved.entry.runId);
// Force an immediate interruption and make steer the next run.
if (targetSessionId) {
abortEmbeddedPiRun(targetSessionId);
}
const cleared = clearSessionQueues([resolved.entry.childSessionKey, targetSessionId]);
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
logVerbose(
`subagents steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
);
}
// Best effort: wait for the interrupted run to settle so the steer
// message is appended on the existing conversation state.
try {
await callGateway({
method: "agent.wait",
params: {
runId: resolved.entry.runId,
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS,
},
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000,
});
} catch {
// Continue even if wait fails; steer should still be attempted.
}
}
const idempotencyKey = crypto.randomUUID();
let runId: string = idempotencyKey;
try {
const response = await callGateway<{ runId: string }>({
method: "agent",
params: {
message,
sessionKey: resolved.entry.childSessionKey,
sessionId: targetSessionId,
idempotencyKey,
deliver: false,
channel: INTERNAL_MESSAGE_CHANNEL,
lane: AGENT_LANE_SUBAGENT,
timeout: 0,
},
timeoutMs: 10_000,
});
const responseRunId = typeof response?.runId === "string" ? response.runId : undefined;
if (responseRunId) {
runId = responseRunId;
}
} catch (err) {
if (steerRequested) {
// Replacement launch failed; restore announce behavior for the
// original run so completion is not silently suppressed.
clearSubagentRunSteerRestart(resolved.entry.runId);
}
const messageText =
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
return { shouldContinue: false, reply: { text: `send failed: ${messageText}` } };
}
if (steerRequested) {
replaceSubagentRunAfterSteer({
previousRunId: resolved.entry.runId,
nextRunId: runId,
fallback: resolved.entry,
runTimeoutSeconds: resolved.entry.runTimeoutSeconds ?? 0,
});
return {
shouldContinue: false,
reply: {
text: `steered ${formatRunLabel(resolved.entry)} (run ${runId.slice(0, 8)}).`,
},
};
}
const waitMs = 30_000;
const wait = await callGateway<{ status?: string; error?: string }>({
method: "agent.wait",
params: { runId, timeoutMs: waitMs },
timeoutMs: waitMs + 2000,
});
if (wait?.status === "timeout") {
return {
shouldContinue: false,
reply: { text: `⏳ Subagent still running (run ${runId.slice(0, 8)}).` },
};
}
if (wait?.status === "error") {
const waitError = typeof wait.error === "string" ? wait.error : "unknown error";
return {
shouldContinue: false,
reply: {
text: `⚠️ Subagent error: ${waitError} (run ${runId.slice(0, 8)}).`,
},
};
}
const history = await callGateway<{ messages: Array<unknown> }>({
method: "chat.history",
params: { sessionKey: resolved.entry.childSessionKey, limit: 50 },
});
const filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []);
const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
const replyText = last ? extractAssistantText(last) : undefined;
return {
shouldContinue: false,
reply: {
text:
replyText ?? `✅ Sent to ${formatRunLabel(resolved.entry)} (run ${runId.slice(0, 8)}).`,
},
};
}
if (action === "spawn") {
const agentId = restTokens[0];
// Parse remaining tokens: task text with optional --model and --thinking flags.
const taskParts: string[] = [];
let model: string | undefined;
let thinking: string | undefined;
for (let i = 1; i < restTokens.length; i++) {
if (restTokens[i] === "--model" && i + 1 < restTokens.length) {
i += 1;
model = restTokens[i];
} else if (restTokens[i] === "--thinking" && i + 1 < restTokens.length) {
i += 1;
thinking = restTokens[i];
} else {
taskParts.push(restTokens[i]);
}
}
const task = taskParts.join(" ").trim();
if (!agentId || !task) {
return {
shouldContinue: false,
reply: {
text: "Usage: /subagents spawn <agentId> <task> [--model <model>] [--thinking <level>]",
},
};
}
const result = await spawnSubagentDirect(
{ task, agentId, model, thinking, cleanup: "keep" },
{
agentSessionKey: requesterKey,
agentChannel: params.command.channel,
agentAccountId: params.ctx.AccountId,
agentTo: params.command.to,
agentThreadId: params.ctx.MessageThreadId,
},
);
if (result.status === "accepted") {
return {
shouldContinue: false,
reply: {
text: `Spawned subagent ${agentId} (session ${result.childSessionKey}, run ${result.runId?.slice(0, 8)}).${result.warning ? ` Warning: ${result.warning}` : ""}`,
},
};
}
return {
shouldContinue: false,
reply: { text: `Spawn failed: ${result.error ?? result.status}` },
};
}
return { shouldContinue: false, reply: { text: buildSubagentsHelp() } };
};