feat: expand subagent status visibility
This commit is contained in:
@@ -22,6 +22,7 @@ Docs: https://docs.clawd.bot
|
||||
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default.
|
||||
- Plugins: allow optional agent tools with explicit allowlists and add plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
|
||||
- Tools: centralize plugin tool policy helpers.
|
||||
- Commands: add `/subagents info` and show sub-agent counts in `/status`.
|
||||
- Docs: clarify plugin agent tool configuration. https://docs.clawd.bot/plugins/agent-tools
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -62,6 +62,7 @@ Text + native (when enabled):
|
||||
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||
- `/usage` (alias: `/status`)
|
||||
- `/whoami` (show your sender id; alias: `/id`)
|
||||
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
|
||||
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
|
||||
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
|
||||
- `/cost on|off` (toggle per-response usage line)
|
||||
|
||||
@@ -9,6 +9,17 @@ read_when:
|
||||
|
||||
Sub-agents are background agent runs spawned from an existing agent run. They run in their own session (`agent:<agentId>:subagent:<uuid>`) and, when finished, **announce** their result back to the requester chat channel.
|
||||
|
||||
## Slash command
|
||||
|
||||
Use `/subagents` to inspect or control sub-agent runs for the **current session**:
|
||||
- `/subagents list`
|
||||
- `/subagents stop <id|#|all>`
|
||||
- `/subagents log <id|#> [limit] [tools]`
|
||||
- `/subagents info <id|#>`
|
||||
- `/subagents send <id|#> <message>`
|
||||
|
||||
`/subagents info` shows run metadata (status, timestamps, session id, transcript path, cleanup).
|
||||
|
||||
Primary goals:
|
||||
- Parallelize “research / long task / slow tool” work without blocking the main run.
|
||||
- Keep sub-agents isolated by default (session separation + optional sandboxing).
|
||||
|
||||
@@ -284,7 +284,7 @@ export function createClawdbotCodingTools(options?: {
|
||||
];
|
||||
const pluginGroups = buildPluginToolGroups({
|
||||
tools,
|
||||
toolMeta: (tool) => getPluginToolMeta(tool),
|
||||
toolMeta: (tool) => getPluginToolMeta(tool as AnyAgentTool),
|
||||
});
|
||||
const profilePolicyExpanded = expandPolicyWithPluginGroups(profilePolicy, pluginGroups);
|
||||
const providerProfileExpanded = expandPolicyWithPluginGroups(providerProfilePolicy, pluginGroups);
|
||||
|
||||
@@ -348,6 +348,11 @@ export function resetSubagentRegistryForTests() {
|
||||
persistSubagentRuns();
|
||||
}
|
||||
|
||||
export function addSubagentRunForTests(entry: SubagentRunRecord) {
|
||||
subagentRuns.set(entry.runId, entry);
|
||||
persistSubagentRuns();
|
||||
}
|
||||
|
||||
export function releaseSubagentRun(runId: string) {
|
||||
const didDelete = subagentRuns.delete(runId);
|
||||
if (didDelete) persistSubagentRuns();
|
||||
|
||||
@@ -144,6 +144,32 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
|
||||
description: "Show your sender id.",
|
||||
textAlias: "/whoami",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "subagents",
|
||||
nativeName: "subagents",
|
||||
description: "List/stop/log/info subagent runs for this session.",
|
||||
textAlias: "/subagents",
|
||||
args: [
|
||||
{
|
||||
name: "action",
|
||||
description: "list | stop | log | info | send",
|
||||
type: "string",
|
||||
choices: ["list", "stop", "log", "info", "send"],
|
||||
},
|
||||
{
|
||||
name: "target",
|
||||
description: "Run id, index, or session key",
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
name: "value",
|
||||
description: "Additional input (limit/message)",
|
||||
type: "string",
|
||||
captureRemaining: true,
|
||||
},
|
||||
],
|
||||
argsMenu: "auto",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "config",
|
||||
nativeName: "config",
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
handleStatusCommand,
|
||||
handleWhoamiCommand,
|
||||
} from "./commands-info.js";
|
||||
import { handleSubagentsCommand } from "./commands-subagents.js";
|
||||
import {
|
||||
handleAbortTrigger,
|
||||
handleActivationCommand,
|
||||
@@ -36,6 +37,7 @@ const HANDLERS: CommandHandler[] = [
|
||||
handleStatusCommand,
|
||||
handleContextCommand,
|
||||
handleWhoamiCommand,
|
||||
handleSubagentsCommand,
|
||||
handleConfigCommand,
|
||||
handleDebugCommand,
|
||||
handleStopCommand,
|
||||
|
||||
@@ -3,12 +3,14 @@ import {
|
||||
resolveDefaultAgentId,
|
||||
resolveSessionAgentId,
|
||||
} from "../../agents/agent-scope.js";
|
||||
import { listSubagentRunsForRequester } from "../../agents/subagent-registry.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
resolveAuthProfileDisplayLabel,
|
||||
resolveAuthProfileOrder,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
|
||||
import { resolveInternalSessionKey, resolveMainSessionAlias } from "../../agents/tools/sessions-helpers.js";
|
||||
import { normalizeProviderId } from "../../agents/model-selection.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { SessionEntry, SessionScope } from "../../config/sessions.js";
|
||||
@@ -171,6 +173,30 @@ export async function buildStatusReply(params: {
|
||||
const queueOverrides = Boolean(
|
||||
sessionEntry?.queueDebounceMs ?? sessionEntry?.queueCap ?? sessionEntry?.queueDrop,
|
||||
);
|
||||
|
||||
let subagentsLine: string | undefined;
|
||||
if (sessionKey) {
|
||||
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
||||
const requesterKey = resolveInternalSessionKey({ key: sessionKey, alias, mainKey });
|
||||
const runs = listSubagentRunsForRequester(requesterKey);
|
||||
const verboseEnabled = resolvedVerboseLevel && resolvedVerboseLevel !== "off";
|
||||
if (runs.length === 0) {
|
||||
if (verboseEnabled) subagentsLine = "🤖 Subagents: none";
|
||||
} else {
|
||||
const active = runs.filter((entry) => !entry.endedAt);
|
||||
const done = runs.length - active.length;
|
||||
if (verboseEnabled) {
|
||||
const labels = active
|
||||
.map((entry) => entry.label?.trim() || entry.task?.trim() || "")
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
const labelText = labels.length ? ` (${labels.join(", ")})` : "";
|
||||
subagentsLine = `🤖 Subagents: ${active.length} active${labelText} · ${done} done`;
|
||||
} else if (active.length > 0) {
|
||||
subagentsLine = `🤖 Subagents: ${active.length} active`;
|
||||
}
|
||||
}
|
||||
}
|
||||
const groupActivation = isGroup
|
||||
? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? defaultGroupActivation())
|
||||
: undefined;
|
||||
@@ -206,6 +232,7 @@ export async function buildStatusReply(params: {
|
||||
dropPolicy: queueSettings.dropPolicy,
|
||||
showDetails: queueOverrides,
|
||||
},
|
||||
subagentsLine,
|
||||
mediaDecisions: params.mediaDecisions,
|
||||
includeTranscriptUsage: false,
|
||||
});
|
||||
|
||||
441
src/auto-reply/reply/commands-subagents.ts
Normal file
441
src/auto-reply/reply/commands-subagents.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js";
|
||||
import { AGENT_LANE_SUBAGENT } from "../../agents/lanes.js";
|
||||
import { listSubagentRunsForRequester } from "../../agents/subagent-registry.js";
|
||||
import {
|
||||
extractAssistantText,
|
||||
resolveInternalSessionKey,
|
||||
resolveMainSessionAlias,
|
||||
stripToolMessages,
|
||||
} from "../../agents/tools/sessions-helpers.js";
|
||||
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
|
||||
import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||
import { truncateUtf16Safe } from "../../utils.js";
|
||||
import { stopSubagentsForRequester } from "./abort.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
import { clearSessionQueues } from "./queue.js";
|
||||
|
||||
type SubagentTargetResolution = {
|
||||
entry?: SubagentRunRecord;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const COMMAND = "/subagents";
|
||||
const ACTIONS = new Set(["list", "stop", "log", "send", "info", "help"]);
|
||||
|
||||
function formatDurationShort(valueMs?: number) {
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) return "n/a";
|
||||
const totalSeconds = Math.round(valueMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0) return `${hours}h${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m${seconds}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function formatAgeShort(valueMs?: number) {
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) return "n/a";
|
||||
const minutes = Math.round(valueMs / 60_000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 48) return `${hours}h ago`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
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)} (${formatAgeShort(Date.now() - valueMs)})`;
|
||||
}
|
||||
|
||||
function formatRunStatus(entry: SubagentRunRecord) {
|
||||
if (!entry.endedAt) return "running";
|
||||
const status = entry.outcome?.status ?? "done";
|
||||
return status === "ok" ? "done" : status;
|
||||
}
|
||||
|
||||
function formatRunLabel(entry: SubagentRunRecord) {
|
||||
const raw = entry.label?.trim() || entry.task?.trim() || "subagent";
|
||||
return raw.length > 72 ? `${truncateUtf16Safe(raw, 72).trimEnd()}…` : raw;
|
||||
}
|
||||
|
||||
function sortRuns(runs: SubagentRunRecord[]) {
|
||||
return [...runs].sort((a, b) => {
|
||||
const aTime = a.startedAt ?? a.createdAt ?? 0;
|
||||
const bTime = b.startedAt ?? b.createdAt ?? 0;
|
||||
return bTime - aTime;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveRequesterSessionKey(params: Parameters<CommandHandler>[0]): string | undefined {
|
||||
const raw = params.ctx.CommandTargetSessionKey?.trim() || params.sessionKey;
|
||||
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 = sortRuns(runs);
|
||||
return { entry: sorted[0] };
|
||||
}
|
||||
const sorted = sortRuns(runs);
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const idx = Number.parseInt(trimmed, 10);
|
||||
if (!Number.isFinite(idx) || idx <= 0 || idx > sorted.length) {
|
||||
return { error: `Invalid subagent index: ${trimmed}` };
|
||||
}
|
||||
return { entry: sorted[idx - 1] };
|
||||
}
|
||||
if (trimmed.includes(":")) {
|
||||
const match = runs.find((entry) => entry.childSessionKey === trimmed);
|
||||
return match ? { entry: match } : { error: `Unknown subagent session: ${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 stop <id|#|all>",
|
||||
"- /subagents log <id|#> [limit] [tools]",
|
||||
"- /subagents info <id|#>",
|
||||
"- /subagents send <id|#> <message>",
|
||||
"",
|
||||
"Ids: use the list index (#), runId prefix, or full session key.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
type ChatMessage = {
|
||||
role?: unknown;
|
||||
content?: unknown;
|
||||
name?: unknown;
|
||||
toolName?: unknown;
|
||||
};
|
||||
|
||||
function normalizeMessageText(text: string) {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function extractMessageText(message: ChatMessage): { role: string; text: string } | null {
|
||||
const role = typeof message.role === "string" ? message.role : "";
|
||||
const content = message.content;
|
||||
if (typeof content === "string") {
|
||||
const normalized = normalizeMessageText(content);
|
||||
return normalized ? { role, text: normalized } : null;
|
||||
}
|
||||
if (!Array.isArray(content)) return null;
|
||||
const chunks: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
if ((block as { type?: unknown }).type !== "text") continue;
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
chunks.push(text);
|
||||
}
|
||||
}
|
||||
const joined = normalizeMessageText(chunks.join(" "));
|
||||
return joined ? { role, text: joined } : 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;
|
||||
}
|
||||
|
||||
function loadSubagentSessionEntry(params: Parameters<CommandHandler>[0], childKey: string) {
|
||||
const parsed = parseAgentSessionKey(childKey);
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed?.agentId });
|
||||
const store = loadSessionStore(storePath);
|
||||
return { storePath, store, entry: store[childKey] };
|
||||
}
|
||||
|
||||
export const handleSubagentsCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
const normalized = params.command.commandBodyNormalized;
|
||||
if (!normalized.startsWith(COMMAND)) return null;
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /subagents from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
const rest = normalized.slice(COMMAND.length).trim();
|
||||
const [actionRaw, ...restTokens] = rest.split(/\s+/).filter(Boolean);
|
||||
const action = actionRaw?.toLowerCase() || "list";
|
||||
if (!ACTIONS.has(action)) {
|
||||
return { shouldContinue: false, reply: { text: buildSubagentsHelp() } };
|
||||
}
|
||||
|
||||
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") {
|
||||
if (runs.length === 0) {
|
||||
return { shouldContinue: false, reply: { text: "🧭 Subagents: none for this session." } };
|
||||
}
|
||||
const sorted = sortRuns(runs);
|
||||
const active = sorted.filter((entry) => !entry.endedAt);
|
||||
const done = sorted.length - active.length;
|
||||
const lines = [
|
||||
"🧭 Subagents (current session)",
|
||||
`Active: ${active.length} · Done: ${done}`,
|
||||
];
|
||||
sorted.forEach((entry, index) => {
|
||||
const status = formatRunStatus(entry);
|
||||
const label = formatRunLabel(entry);
|
||||
const runtime =
|
||||
entry.endedAt && entry.startedAt
|
||||
? formatDurationShort(entry.endedAt - entry.startedAt)
|
||||
: formatAgeShort(Date.now() - (entry.startedAt ?? entry.createdAt));
|
||||
const runId = entry.runId.slice(0, 8);
|
||||
lines.push(
|
||||
`${index + 1}) ${status} · ${label} · ${runtime} · run ${runId} · ${entry.childSessionKey}`,
|
||||
);
|
||||
});
|
||||
return { shouldContinue: false, reply: { text: lines.join("\n") } };
|
||||
}
|
||||
|
||||
if (action === "stop") {
|
||||
const target = restTokens[0];
|
||||
if (!target) {
|
||||
return { shouldContinue: false, reply: { text: "⚙️ Usage: /subagents stop <id|#|all>" } };
|
||||
}
|
||||
if (target === "all" || target === "*") {
|
||||
const { stopped } = stopSubagentsForRequester({
|
||||
cfg: params.cfg,
|
||||
requesterSessionKey: requesterKey,
|
||||
});
|
||||
const label = stopped === 1 ? "subagent" : "subagents";
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚙️ Stopped ${stopped} ${label}.` },
|
||||
};
|
||||
}
|
||||
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: "⚙️ Subagent 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 stop: 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;
|
||||
});
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚙️ Stop requested for ${formatRunLabel(resolved.entry)}.` },
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
? formatDurationShort((run.endedAt ?? Date.now()) - run.startedAt)
|
||||
: "n/a";
|
||||
const outcome = run.outcome
|
||||
? `${run.outcome.status}${run.outcome.error ? ` (${run.outcome.error})` : ""}`
|
||||
: "n/a";
|
||||
const lines = [
|
||||
"ℹ️ Subagent info",
|
||||
`Status: ${formatRunStatus(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({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: resolved.entry.childSessionKey, limit },
|
||||
})) as { messages?: unknown[] };
|
||||
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") {
|
||||
const target = restTokens[0];
|
||||
const message = restTokens.slice(1).join(" ").trim();
|
||||
if (!target || !message) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "✉️ Usage: /subagents send <id|#> <message>" },
|
||||
};
|
||||
}
|
||||
const resolved = resolveSubagentTarget(runs, target);
|
||||
if (!resolved.entry) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` },
|
||||
};
|
||||
}
|
||||
const idempotencyKey = crypto.randomUUID();
|
||||
let runId: string = idempotencyKey;
|
||||
try {
|
||||
const response = (await callGateway({
|
||||
method: "agent",
|
||||
params: {
|
||||
message,
|
||||
sessionKey: resolved.entry.childSessionKey,
|
||||
idempotencyKey,
|
||||
deliver: false,
|
||||
channel: INTERNAL_MESSAGE_CHANNEL,
|
||||
lane: AGENT_LANE_SUBAGENT,
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
})) as { runId?: string };
|
||||
if (response?.runId) runId = response.runId;
|
||||
} catch (err) {
|
||||
const messageText =
|
||||
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
|
||||
return { shouldContinue: false, reply: { text: `⚠️ Send failed: ${messageText}` } };
|
||||
}
|
||||
|
||||
const waitMs = 30_000;
|
||||
const wait = (await callGateway({
|
||||
method: "agent.wait",
|
||||
params: { runId, timeoutMs: waitMs },
|
||||
timeoutMs: waitMs + 2000,
|
||||
})) as { status?: string; error?: string };
|
||||
if (wait?.status === "timeout") {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⏳ Subagent still running (run ${runId.slice(0, 8)}).` },
|
||||
};
|
||||
}
|
||||
if (wait?.status === "error") {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚠️ Subagent error: ${wait.error ?? "unknown error"} (run ${runId.slice(0, 8)}).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const history = (await callGateway({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: resolved.entry.childSessionKey, limit: 50 },
|
||||
})) as { messages?: unknown[] };
|
||||
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)}).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { shouldContinue: false, reply: { text: buildSubagentsHelp() } };
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { addSubagentRunForTests, resetSubagentRegistryForTests } from "../../agents/subagent-registry.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import * as internalHooks from "../../hooks/internal-hooks.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
@@ -197,3 +198,128 @@ describe("handleCommands context", () => {
|
||||
expect(result.reply?.text).toContain("Top tools (schema size):");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands subagents", () => {
|
||||
it("lists subagents when none exist", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/subagents list", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Subagents: none");
|
||||
});
|
||||
|
||||
it("returns help for unknown subagents action", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/subagents foo", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("/subagents");
|
||||
});
|
||||
|
||||
it("returns usage for subagents info without target", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/subagents info", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("/subagents info");
|
||||
});
|
||||
|
||||
it("includes subagent count in /status when active", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
addSubagentRunForTests({
|
||||
runId: "run-1",
|
||||
childSessionKey: "agent:main:subagent:abc",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "do thing",
|
||||
cleanup: "keep",
|
||||
createdAt: 1000,
|
||||
startedAt: 1000,
|
||||
});
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/status", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("🤖 Subagents: 1 active");
|
||||
});
|
||||
|
||||
it("includes subagent details in /status when verbose", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
addSubagentRunForTests({
|
||||
runId: "run-1",
|
||||
childSessionKey: "agent:main:subagent:abc",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "do thing",
|
||||
cleanup: "keep",
|
||||
createdAt: 1000,
|
||||
startedAt: 1000,
|
||||
});
|
||||
addSubagentRunForTests({
|
||||
runId: "run-2",
|
||||
childSessionKey: "agent:main:subagent:def",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "finished task",
|
||||
cleanup: "keep",
|
||||
createdAt: 900,
|
||||
startedAt: 900,
|
||||
endedAt: 1200,
|
||||
outcome: { status: "ok" },
|
||||
});
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/status", cfg);
|
||||
params.resolvedVerboseLevel = "on";
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("🤖 Subagents: 1 active");
|
||||
expect(result.reply?.text).toContain("· 1 done");
|
||||
});
|
||||
|
||||
it("returns info for a subagent", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
addSubagentRunForTests({
|
||||
runId: "run-1",
|
||||
childSessionKey: "agent:main:subagent:abc",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "do thing",
|
||||
cleanup: "keep",
|
||||
createdAt: 1000,
|
||||
startedAt: 1000,
|
||||
endedAt: 2000,
|
||||
outcome: { status: "ok" },
|
||||
});
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/subagents info 1", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Subagent info");
|
||||
expect(result.reply?.text).toContain("Run: run-1");
|
||||
expect(result.reply?.text).toContain("Status: done");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +54,7 @@ type StatusArgs = {
|
||||
usageLine?: string;
|
||||
queue?: QueueStatus;
|
||||
mediaDecisions?: MediaUnderstandingDecision[];
|
||||
subagentsLine?: string;
|
||||
includeTranscriptUsage?: boolean;
|
||||
now?: number;
|
||||
};
|
||||
@@ -367,6 +368,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
mediaLine,
|
||||
args.usageLine,
|
||||
`🧵 ${sessionLine}`,
|
||||
args.subagentsLine,
|
||||
`⚙️ ${optionsLine}`,
|
||||
activationLine,
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user