From b105745299da11610ed33d06de822e3ca5651256 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 04:44:52 +0000 Subject: [PATCH] feat: expand subagent status visibility --- CHANGELOG.md | 1 + docs/tools/slash-commands.md | 1 + docs/tools/subagents.md | 11 + src/agents/pi-tools.ts | 2 +- src/agents/subagent-registry.ts | 5 + src/auto-reply/commands-registry.data.ts | 26 ++ src/auto-reply/reply/commands-core.ts | 2 + src/auto-reply/reply/commands-status.ts | 27 ++ src/auto-reply/reply/commands-subagents.ts | 441 +++++++++++++++++++++ src/auto-reply/reply/commands.test.ts | 126 ++++++ src/auto-reply/status.ts | 2 + 11 files changed, 643 insertions(+), 1 deletion(-) create mode 100644 src/auto-reply/reply/commands-subagents.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ab88baba..8b13ed56b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 65279a198..50acbc871 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -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) diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index d17191960..36b93d416 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -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::subagent:`) 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 ` +- `/subagents log [limit] [tools]` +- `/subagents info ` +- `/subagents send ` + +`/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). diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 07f955415..07c158a69 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -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); diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 701dcf56d..d39bb5fe4 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -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(); diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index bc007b188..7d9607853 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -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", diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index e708aaa1d..9afb24f1d 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -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, diff --git a/src/auto-reply/reply/commands-status.ts b/src/auto-reply/reply/commands-status.ts index 2249f9fc6..7fb5df45f 100644 --- a/src/auto-reply/reply/commands-status.ts +++ b/src/auto-reply/reply/commands-status.ts @@ -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, }); diff --git a/src/auto-reply/reply/commands-subagents.ts b/src/auto-reply/reply/commands-subagents.ts new file mode 100644 index 000000000..892232bb3 --- /dev/null +++ b/src/auto-reply/reply/commands-subagents.ts @@ -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[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 ", + "- /subagents log [limit] [tools]", + "- /subagents info ", + "- /subagents send ", + "", + "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[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 || ""}`, + ); + 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 " } }; + } + 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 " } }; + } + 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 [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 " }, + }; + } + 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() } }; +}; diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 41d3b5437..6312d0ca8 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -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"); + }); +}); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 8a703357a..28b59321f 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -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, ]