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.
|
- 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
|
- 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.
|
- 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
|
- Docs: clarify plugin agent tool configuration. https://docs.clawd.bot/plugins/agent-tools
|
||||||
|
|
||||||
### Fixes
|
### 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)
|
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||||
- `/usage` (alias: `/status`)
|
- `/usage` (alias: `/status`)
|
||||||
- `/whoami` (show your sender id; alias: `/id`)
|
- `/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`)
|
- `/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`)
|
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
|
||||||
- `/cost on|off` (toggle per-response usage line)
|
- `/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.
|
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:
|
Primary goals:
|
||||||
- Parallelize “research / long task / slow tool” work without blocking the main run.
|
- Parallelize “research / long task / slow tool” work without blocking the main run.
|
||||||
- Keep sub-agents isolated by default (session separation + optional sandboxing).
|
- Keep sub-agents isolated by default (session separation + optional sandboxing).
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
];
|
];
|
||||||
const pluginGroups = buildPluginToolGroups({
|
const pluginGroups = buildPluginToolGroups({
|
||||||
tools,
|
tools,
|
||||||
toolMeta: (tool) => getPluginToolMeta(tool),
|
toolMeta: (tool) => getPluginToolMeta(tool as AnyAgentTool),
|
||||||
});
|
});
|
||||||
const profilePolicyExpanded = expandPolicyWithPluginGroups(profilePolicy, pluginGroups);
|
const profilePolicyExpanded = expandPolicyWithPluginGroups(profilePolicy, pluginGroups);
|
||||||
const providerProfileExpanded = expandPolicyWithPluginGroups(providerProfilePolicy, pluginGroups);
|
const providerProfileExpanded = expandPolicyWithPluginGroups(providerProfilePolicy, pluginGroups);
|
||||||
|
|||||||
@@ -348,6 +348,11 @@ export function resetSubagentRegistryForTests() {
|
|||||||
persistSubagentRuns();
|
persistSubagentRuns();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addSubagentRunForTests(entry: SubagentRunRecord) {
|
||||||
|
subagentRuns.set(entry.runId, entry);
|
||||||
|
persistSubagentRuns();
|
||||||
|
}
|
||||||
|
|
||||||
export function releaseSubagentRun(runId: string) {
|
export function releaseSubagentRun(runId: string) {
|
||||||
const didDelete = subagentRuns.delete(runId);
|
const didDelete = subagentRuns.delete(runId);
|
||||||
if (didDelete) persistSubagentRuns();
|
if (didDelete) persistSubagentRuns();
|
||||||
|
|||||||
@@ -144,6 +144,32 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
|
|||||||
description: "Show your sender id.",
|
description: "Show your sender id.",
|
||||||
textAlias: "/whoami",
|
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({
|
defineChatCommand({
|
||||||
key: "config",
|
key: "config",
|
||||||
nativeName: "config",
|
nativeName: "config",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
handleStatusCommand,
|
handleStatusCommand,
|
||||||
handleWhoamiCommand,
|
handleWhoamiCommand,
|
||||||
} from "./commands-info.js";
|
} from "./commands-info.js";
|
||||||
|
import { handleSubagentsCommand } from "./commands-subagents.js";
|
||||||
import {
|
import {
|
||||||
handleAbortTrigger,
|
handleAbortTrigger,
|
||||||
handleActivationCommand,
|
handleActivationCommand,
|
||||||
@@ -36,6 +37,7 @@ const HANDLERS: CommandHandler[] = [
|
|||||||
handleStatusCommand,
|
handleStatusCommand,
|
||||||
handleContextCommand,
|
handleContextCommand,
|
||||||
handleWhoamiCommand,
|
handleWhoamiCommand,
|
||||||
|
handleSubagentsCommand,
|
||||||
handleConfigCommand,
|
handleConfigCommand,
|
||||||
handleDebugCommand,
|
handleDebugCommand,
|
||||||
handleStopCommand,
|
handleStopCommand,
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import {
|
|||||||
resolveDefaultAgentId,
|
resolveDefaultAgentId,
|
||||||
resolveSessionAgentId,
|
resolveSessionAgentId,
|
||||||
} from "../../agents/agent-scope.js";
|
} from "../../agents/agent-scope.js";
|
||||||
|
import { listSubagentRunsForRequester } from "../../agents/subagent-registry.js";
|
||||||
import {
|
import {
|
||||||
ensureAuthProfileStore,
|
ensureAuthProfileStore,
|
||||||
resolveAuthProfileDisplayLabel,
|
resolveAuthProfileDisplayLabel,
|
||||||
resolveAuthProfileOrder,
|
resolveAuthProfileOrder,
|
||||||
} from "../../agents/auth-profiles.js";
|
} from "../../agents/auth-profiles.js";
|
||||||
import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.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 { normalizeProviderId } from "../../agents/model-selection.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import type { SessionEntry, SessionScope } from "../../config/sessions.js";
|
import type { SessionEntry, SessionScope } from "../../config/sessions.js";
|
||||||
@@ -171,6 +173,30 @@ export async function buildStatusReply(params: {
|
|||||||
const queueOverrides = Boolean(
|
const queueOverrides = Boolean(
|
||||||
sessionEntry?.queueDebounceMs ?? sessionEntry?.queueCap ?? sessionEntry?.queueDrop,
|
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
|
const groupActivation = isGroup
|
||||||
? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? defaultGroupActivation())
|
? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? defaultGroupActivation())
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -206,6 +232,7 @@ export async function buildStatusReply(params: {
|
|||||||
dropPolicy: queueSettings.dropPolicy,
|
dropPolicy: queueSettings.dropPolicy,
|
||||||
showDetails: queueOverrides,
|
showDetails: queueOverrides,
|
||||||
},
|
},
|
||||||
|
subagentsLine,
|
||||||
mediaDecisions: params.mediaDecisions,
|
mediaDecisions: params.mediaDecisions,
|
||||||
includeTranscriptUsage: false,
|
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 { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { addSubagentRunForTests, resetSubagentRegistryForTests } from "../../agents/subagent-registry.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import * as internalHooks from "../../hooks/internal-hooks.js";
|
import * as internalHooks from "../../hooks/internal-hooks.js";
|
||||||
import type { MsgContext } from "../templating.js";
|
import type { MsgContext } from "../templating.js";
|
||||||
@@ -197,3 +198,128 @@ describe("handleCommands context", () => {
|
|||||||
expect(result.reply?.text).toContain("Top tools (schema size):");
|
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;
|
usageLine?: string;
|
||||||
queue?: QueueStatus;
|
queue?: QueueStatus;
|
||||||
mediaDecisions?: MediaUnderstandingDecision[];
|
mediaDecisions?: MediaUnderstandingDecision[];
|
||||||
|
subagentsLine?: string;
|
||||||
includeTranscriptUsage?: boolean;
|
includeTranscriptUsage?: boolean;
|
||||||
now?: number;
|
now?: number;
|
||||||
};
|
};
|
||||||
@@ -367,6 +368,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
|||||||
mediaLine,
|
mediaLine,
|
||||||
args.usageLine,
|
args.usageLine,
|
||||||
`🧵 ${sessionLine}`,
|
`🧵 ${sessionLine}`,
|
||||||
|
args.subagentsLine,
|
||||||
`⚙️ ${optionsLine}`,
|
`⚙️ ${optionsLine}`,
|
||||||
activationLine,
|
activationLine,
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user