mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 04:41:40 +00:00
feat: thread-bound subagents on Discord (#21805)
* docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
This commit is contained in:
55
src/auto-reply/reply/commands-subagents/action-agents.ts
Normal file
55
src/auto-reply/reply/commands-subagents/action-agents.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { getThreadBindingManager } from "../../../discord/monitor/thread-bindings.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import { formatRunLabel, sortSubagentRuns } from "../subagents-utils.js";
|
||||
import {
|
||||
type SubagentsCommandContext,
|
||||
isDiscordSurface,
|
||||
resolveDiscordAccountId,
|
||||
stopWithText,
|
||||
} from "./shared.js";
|
||||
|
||||
export function handleSubagentsAgentsAction(ctx: SubagentsCommandContext): CommandHandlerResult {
|
||||
const { params, requesterKey, runs } = ctx;
|
||||
const isDiscord = isDiscordSurface(params);
|
||||
const accountId = isDiscord ? resolveDiscordAccountId(params) : undefined;
|
||||
const threadBindings = accountId ? getThreadBindingManager(accountId) : null;
|
||||
const visibleRuns = sortSubagentRuns(runs).filter((entry) => {
|
||||
if (!entry.endedAt) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(threadBindings?.listBySessionKey(entry.childSessionKey)[0]);
|
||||
});
|
||||
|
||||
const lines = ["agents:", "-----"];
|
||||
if (visibleRuns.length === 0) {
|
||||
lines.push("(none)");
|
||||
} else {
|
||||
let index = 1;
|
||||
for (const entry of visibleRuns) {
|
||||
const threadBinding = threadBindings?.listBySessionKey(entry.childSessionKey)[0];
|
||||
const bindingText = threadBinding
|
||||
? `thread:${threadBinding.threadId}`
|
||||
: isDiscord
|
||||
? "unbound"
|
||||
: "bindings available on discord";
|
||||
lines.push(`${index}. ${formatRunLabel(entry)} (${bindingText})`);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (threadBindings) {
|
||||
const acpBindings = threadBindings
|
||||
.listBindings()
|
||||
.filter((entry) => entry.targetKind === "acp" && entry.targetSessionKey === requesterKey);
|
||||
if (acpBindings.length > 0) {
|
||||
lines.push("", "acp/session bindings:", "-----");
|
||||
for (const binding of acpBindings) {
|
||||
lines.push(
|
||||
`- ${binding.label ?? binding.targetSessionKey} (thread:${binding.threadId}, session:${binding.targetSessionKey})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stopWithText(lines.join("\n"));
|
||||
}
|
||||
90
src/auto-reply/reply/commands-subagents/action-focus.ts
Normal file
90
src/auto-reply/reply/commands-subagents/action-focus.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
getThreadBindingManager,
|
||||
resolveThreadBindingIntroText,
|
||||
resolveThreadBindingThreadName,
|
||||
} from "../../../discord/monitor/thread-bindings.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import {
|
||||
type SubagentsCommandContext,
|
||||
isDiscordSurface,
|
||||
resolveDiscordAccountId,
|
||||
resolveDiscordChannelIdForFocus,
|
||||
resolveFocusTargetSession,
|
||||
stopWithText,
|
||||
} from "./shared.js";
|
||||
|
||||
export async function handleSubagentsFocusAction(
|
||||
ctx: SubagentsCommandContext,
|
||||
): Promise<CommandHandlerResult> {
|
||||
const { params, runs, restTokens } = ctx;
|
||||
if (!isDiscordSurface(params)) {
|
||||
return stopWithText("⚠️ /focus is only available on Discord.");
|
||||
}
|
||||
|
||||
const token = restTokens.join(" ").trim();
|
||||
if (!token) {
|
||||
return stopWithText("Usage: /focus <subagent-label|session-key|session-id|session-label>");
|
||||
}
|
||||
|
||||
const accountId = resolveDiscordAccountId(params);
|
||||
const threadBindings = getThreadBindingManager(accountId);
|
||||
if (!threadBindings) {
|
||||
return stopWithText("⚠️ Discord thread bindings are unavailable for this account.");
|
||||
}
|
||||
|
||||
const focusTarget = await resolveFocusTargetSession({ runs, token });
|
||||
if (!focusTarget) {
|
||||
return stopWithText(`⚠️ Unable to resolve focus target: ${token}`);
|
||||
}
|
||||
|
||||
const currentThreadId =
|
||||
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
|
||||
const parentChannelId = currentThreadId ? undefined : resolveDiscordChannelIdForFocus(params);
|
||||
if (!currentThreadId && !parentChannelId) {
|
||||
return stopWithText("⚠️ Could not resolve a Discord channel for /focus.");
|
||||
}
|
||||
|
||||
const senderId = params.command.senderId?.trim() || "";
|
||||
if (currentThreadId) {
|
||||
const existingBinding = threadBindings.getByThreadId(currentThreadId);
|
||||
if (
|
||||
existingBinding &&
|
||||
existingBinding.boundBy &&
|
||||
existingBinding.boundBy !== "system" &&
|
||||
senderId &&
|
||||
senderId !== existingBinding.boundBy
|
||||
) {
|
||||
return stopWithText(`⚠️ Only ${existingBinding.boundBy} can refocus this thread.`);
|
||||
}
|
||||
}
|
||||
|
||||
const label = focusTarget.label || token;
|
||||
const binding = await threadBindings.bindTarget({
|
||||
threadId: currentThreadId || undefined,
|
||||
channelId: parentChannelId,
|
||||
createThread: !currentThreadId,
|
||||
threadName: resolveThreadBindingThreadName({
|
||||
agentId: focusTarget.agentId,
|
||||
label,
|
||||
}),
|
||||
targetKind: focusTarget.targetKind,
|
||||
targetSessionKey: focusTarget.targetSessionKey,
|
||||
agentId: focusTarget.agentId,
|
||||
label,
|
||||
boundBy: senderId || "unknown",
|
||||
introText: resolveThreadBindingIntroText({
|
||||
agentId: focusTarget.agentId,
|
||||
label,
|
||||
sessionTtlMs: threadBindings.getSessionTtlMs(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!binding) {
|
||||
return stopWithText("⚠️ Failed to bind a Discord thread to the target session.");
|
||||
}
|
||||
|
||||
const actionText = currentThreadId
|
||||
? `bound this thread to ${binding.targetSessionKey}`
|
||||
: `created thread ${binding.threadId} and bound it to ${binding.targetSessionKey}`;
|
||||
return stopWithText(`✅ ${actionText} (${binding.targetKind}).`);
|
||||
}
|
||||
6
src/auto-reply/reply/commands-subagents/action-help.ts
Normal file
6
src/auto-reply/reply/commands-subagents/action-help.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import { buildSubagentsHelp, stopWithText } from "./shared.js";
|
||||
|
||||
export function handleSubagentsHelpAction(): CommandHandlerResult {
|
||||
return stopWithText(buildSubagentsHelp());
|
||||
}
|
||||
59
src/auto-reply/reply/commands-subagents/action-info.ts
Normal file
59
src/auto-reply/reply/commands-subagents/action-info.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js";
|
||||
import { formatDurationCompact } from "../../../shared/subagents-format.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import { formatRunLabel } from "../subagents-utils.js";
|
||||
import {
|
||||
type SubagentsCommandContext,
|
||||
formatTimestampWithAge,
|
||||
loadSubagentSessionEntry,
|
||||
resolveDisplayStatus,
|
||||
resolveSubagentEntryForToken,
|
||||
stopWithText,
|
||||
} from "./shared.js";
|
||||
|
||||
export function handleSubagentsInfoAction(ctx: SubagentsCommandContext): CommandHandlerResult {
|
||||
const { params, runs, restTokens } = ctx;
|
||||
const target = restTokens[0];
|
||||
if (!target) {
|
||||
return stopWithText("ℹ️ Usage: /subagents info <id|#>");
|
||||
}
|
||||
|
||||
const targetResolution = resolveSubagentEntryForToken(runs, target);
|
||||
if ("reply" in targetResolution) {
|
||||
return targetResolution.reply;
|
||||
}
|
||||
|
||||
const run = targetResolution.entry;
|
||||
const { entry: sessionEntry } = loadSubagentSessionEntry(params, run.childSessionKey, {
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
});
|
||||
const runtime =
|
||||
run.startedAt && Number.isFinite(run.startedAt)
|
||||
? (formatDurationCompact((run.endedAt ?? Date.now()) - run.startedAt) ?? "n/a")
|
||||
: "n/a";
|
||||
const outcome = run.outcome
|
||||
? `${run.outcome.status}${run.outcome.error ? ` (${run.outcome.error})` : ""}`
|
||||
: "n/a";
|
||||
|
||||
const lines = [
|
||||
"ℹ️ Subagent info",
|
||||
`Status: ${resolveDisplayStatus(run)}`,
|
||||
`Label: ${formatRunLabel(run)}`,
|
||||
`Task: ${run.task}`,
|
||||
`Run: ${run.runId}`,
|
||||
`Session: ${run.childSessionKey}`,
|
||||
`SessionId: ${sessionEntry?.sessionId ?? "n/a"}`,
|
||||
`Transcript: ${sessionEntry?.sessionFile ?? "n/a"}`,
|
||||
`Runtime: ${runtime}`,
|
||||
`Created: ${formatTimestampWithAge(run.createdAt)}`,
|
||||
`Started: ${formatTimestampWithAge(run.startedAt)}`,
|
||||
`Ended: ${formatTimestampWithAge(run.endedAt)}`,
|
||||
`Cleanup: ${run.cleanup}`,
|
||||
run.archiveAtMs ? `Archive: ${formatTimestampWithAge(run.archiveAtMs)}` : undefined,
|
||||
run.cleanupHandled ? "Cleanup handled: yes" : undefined,
|
||||
`Outcome: ${outcome}`,
|
||||
].filter(Boolean);
|
||||
|
||||
return stopWithText(lines.join("\n"));
|
||||
}
|
||||
86
src/auto-reply/reply/commands-subagents/action-kill.ts
Normal file
86
src/auto-reply/reply/commands-subagents/action-kill.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { abortEmbeddedPiRun } from "../../../agents/pi-embedded.js";
|
||||
import { markSubagentRunTerminated } from "../../../agents/subagent-registry.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
updateSessionStore,
|
||||
} from "../../../config/sessions.js";
|
||||
import { logVerbose } from "../../../globals.js";
|
||||
import { stopSubagentsForRequester } from "../abort.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import { clearSessionQueues } from "../queue.js";
|
||||
import { formatRunLabel } from "../subagents-utils.js";
|
||||
import {
|
||||
type SubagentsCommandContext,
|
||||
COMMAND,
|
||||
loadSubagentSessionEntry,
|
||||
resolveSubagentEntryForToken,
|
||||
stopWithText,
|
||||
} from "./shared.js";
|
||||
|
||||
export async function handleSubagentsKillAction(
|
||||
ctx: SubagentsCommandContext,
|
||||
): Promise<CommandHandlerResult> {
|
||||
const { params, handledPrefix, requesterKey, runs, restTokens } = ctx;
|
||||
const target = restTokens[0];
|
||||
if (!target) {
|
||||
return stopWithText(
|
||||
handledPrefix === COMMAND ? "Usage: /subagents kill <id|#|all>" : "Usage: /kill <id|#|all>",
|
||||
);
|
||||
}
|
||||
|
||||
if (target === "all" || target === "*") {
|
||||
stopSubagentsForRequester({
|
||||
cfg: params.cfg,
|
||||
requesterSessionKey: requesterKey,
|
||||
});
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
const targetResolution = resolveSubagentEntryForToken(runs, target);
|
||||
if ("reply" in targetResolution) {
|
||||
return targetResolution.reply;
|
||||
}
|
||||
if (targetResolution.entry.endedAt) {
|
||||
return stopWithText(`${formatRunLabel(targetResolution.entry)} is already finished.`);
|
||||
}
|
||||
|
||||
const childKey = targetResolution.entry.childSessionKey;
|
||||
const { storePath, store, entry } = loadSubagentSessionEntry(params, childKey, {
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
});
|
||||
const sessionId = entry?.sessionId;
|
||||
if (sessionId) {
|
||||
abortEmbeddedPiRun(sessionId);
|
||||
}
|
||||
|
||||
const cleared = clearSessionQueues([childKey, sessionId]);
|
||||
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
|
||||
logVerbose(
|
||||
`subagents kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (entry) {
|
||||
entry.abortedLastRun = true;
|
||||
entry.updatedAt = Date.now();
|
||||
store[childKey] = entry;
|
||||
await updateSessionStore(storePath, (nextStore) => {
|
||||
nextStore[childKey] = entry;
|
||||
});
|
||||
}
|
||||
|
||||
markSubagentRunTerminated({
|
||||
runId: targetResolution.entry.runId,
|
||||
childSessionKey: childKey,
|
||||
reason: "killed",
|
||||
});
|
||||
|
||||
stopSubagentsForRequester({
|
||||
cfg: params.cfg,
|
||||
requesterSessionKey: childKey,
|
||||
});
|
||||
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
66
src/auto-reply/reply/commands-subagents/action-list.ts
Normal file
66
src/auto-reply/reply/commands-subagents/action-list.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import { sortSubagentRuns } from "../subagents-utils.js";
|
||||
import {
|
||||
type SessionStoreCache,
|
||||
type SubagentsCommandContext,
|
||||
RECENT_WINDOW_MINUTES,
|
||||
formatSubagentListLine,
|
||||
loadSubagentSessionEntry,
|
||||
stopWithText,
|
||||
} from "./shared.js";
|
||||
|
||||
export function handleSubagentsListAction(ctx: SubagentsCommandContext): CommandHandlerResult {
|
||||
const { params, runs } = ctx;
|
||||
const sorted = sortSubagentRuns(runs);
|
||||
const now = Date.now();
|
||||
const recentCutoff = now - RECENT_WINDOW_MINUTES * 60_000;
|
||||
const storeCache: SessionStoreCache = new Map();
|
||||
let index = 1;
|
||||
|
||||
const mapRuns = (entries: typeof runs, runtimeMs: (entry: (typeof runs)[number]) => number) =>
|
||||
entries.map((entry) => {
|
||||
const { entry: sessionEntry } = loadSubagentSessionEntry(
|
||||
params,
|
||||
entry.childSessionKey,
|
||||
{
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
},
|
||||
storeCache,
|
||||
);
|
||||
const line = formatSubagentListLine({
|
||||
entry,
|
||||
index,
|
||||
runtimeMs: runtimeMs(entry),
|
||||
sessionEntry,
|
||||
});
|
||||
index += 1;
|
||||
return line;
|
||||
});
|
||||
|
||||
const activeEntries = sorted.filter((entry) => !entry.endedAt);
|
||||
const activeLines = mapRuns(activeEntries, (entry) => now - (entry.startedAt ?? entry.createdAt));
|
||||
const recentEntries = sorted.filter(
|
||||
(entry) => !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff,
|
||||
);
|
||||
const recentLines = mapRuns(
|
||||
recentEntries,
|
||||
(entry) => (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt),
|
||||
);
|
||||
|
||||
const lines = ["active subagents:", "-----"];
|
||||
if (activeLines.length === 0) {
|
||||
lines.push("(none)");
|
||||
} else {
|
||||
lines.push(activeLines.join("\n"));
|
||||
}
|
||||
lines.push("", `recent subagents (last ${RECENT_WINDOW_MINUTES}m):`, "-----");
|
||||
if (recentLines.length === 0) {
|
||||
lines.push("(none)");
|
||||
} else {
|
||||
lines.push(recentLines.join("\n"));
|
||||
}
|
||||
|
||||
return stopWithText(lines.join("\n"));
|
||||
}
|
||||
43
src/auto-reply/reply/commands-subagents/action-log.ts
Normal file
43
src/auto-reply/reply/commands-subagents/action-log.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { callGateway } from "../../../gateway/call.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import { formatRunLabel } from "../subagents-utils.js";
|
||||
import {
|
||||
type ChatMessage,
|
||||
type SubagentsCommandContext,
|
||||
formatLogLines,
|
||||
resolveSubagentEntryForToken,
|
||||
stopWithText,
|
||||
stripToolMessages,
|
||||
} from "./shared.js";
|
||||
|
||||
export async function handleSubagentsLogAction(
|
||||
ctx: SubagentsCommandContext,
|
||||
): Promise<CommandHandlerResult> {
|
||||
const { runs, restTokens } = ctx;
|
||||
const target = restTokens[0];
|
||||
if (!target) {
|
||||
return stopWithText("📜 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 targetResolution = resolveSubagentEntryForToken(runs, target);
|
||||
if ("reply" in targetResolution) {
|
||||
return targetResolution.reply;
|
||||
}
|
||||
|
||||
const history = await callGateway<{ messages: Array<unknown> }>({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: targetResolution.entry.childSessionKey, limit },
|
||||
});
|
||||
const rawMessages = Array.isArray(history?.messages) ? history.messages : [];
|
||||
const filtered = includeTools ? rawMessages : stripToolMessages(rawMessages);
|
||||
const lines = formatLogLines(filtered as ChatMessage[]);
|
||||
const header = `📜 Subagent log: ${formatRunLabel(targetResolution.entry)}`;
|
||||
if (lines.length === 0) {
|
||||
return stopWithText(`${header}\n(no messages)`);
|
||||
}
|
||||
return stopWithText([header, ...lines].join("\n"));
|
||||
}
|
||||
159
src/auto-reply/reply/commands-subagents/action-send.ts
Normal file
159
src/auto-reply/reply/commands-subagents/action-send.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import crypto from "node:crypto";
|
||||
import { AGENT_LANE_SUBAGENT } from "../../../agents/lanes.js";
|
||||
import { abortEmbeddedPiRun } from "../../../agents/pi-embedded.js";
|
||||
import {
|
||||
clearSubagentRunSteerRestart,
|
||||
replaceSubagentRunAfterSteer,
|
||||
markSubagentRunForSteerRestart,
|
||||
} from "../../../agents/subagent-registry.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js";
|
||||
import { callGateway } from "../../../gateway/call.js";
|
||||
import { logVerbose } from "../../../globals.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../../utils/message-channel.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import { clearSessionQueues } from "../queue.js";
|
||||
import { formatRunLabel } from "../subagents-utils.js";
|
||||
import {
|
||||
type SubagentsCommandContext,
|
||||
COMMAND,
|
||||
STEER_ABORT_SETTLE_TIMEOUT_MS,
|
||||
extractAssistantText,
|
||||
loadSubagentSessionEntry,
|
||||
resolveSubagentEntryForToken,
|
||||
stopWithText,
|
||||
stripToolMessages,
|
||||
} from "./shared.js";
|
||||
|
||||
export async function handleSubagentsSendAction(
|
||||
ctx: SubagentsCommandContext,
|
||||
steerRequested: boolean,
|
||||
): Promise<CommandHandlerResult> {
|
||||
const { params, handledPrefix, runs, restTokens } = ctx;
|
||||
const target = restTokens[0];
|
||||
const message = restTokens.slice(1).join(" ").trim();
|
||||
if (!target || !message) {
|
||||
return stopWithText(
|
||||
steerRequested
|
||||
? handledPrefix === COMMAND
|
||||
? "Usage: /subagents steer <id|#> <message>"
|
||||
: `Usage: ${handledPrefix} <id|#> <message>`
|
||||
: "Usage: /subagents send <id|#> <message>",
|
||||
);
|
||||
}
|
||||
|
||||
const targetResolution = resolveSubagentEntryForToken(runs, target);
|
||||
if ("reply" in targetResolution) {
|
||||
return targetResolution.reply;
|
||||
}
|
||||
if (steerRequested && targetResolution.entry.endedAt) {
|
||||
return stopWithText(`${formatRunLabel(targetResolution.entry)} is already finished.`);
|
||||
}
|
||||
|
||||
const { entry: targetSessionEntry } = loadSubagentSessionEntry(
|
||||
params,
|
||||
targetResolution.entry.childSessionKey,
|
||||
{
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
},
|
||||
);
|
||||
const targetSessionId =
|
||||
typeof targetSessionEntry?.sessionId === "string" && targetSessionEntry.sessionId.trim()
|
||||
? targetSessionEntry.sessionId.trim()
|
||||
: undefined;
|
||||
|
||||
if (steerRequested) {
|
||||
markSubagentRunForSteerRestart(targetResolution.entry.runId);
|
||||
|
||||
if (targetSessionId) {
|
||||
abortEmbeddedPiRun(targetSessionId);
|
||||
}
|
||||
|
||||
const cleared = clearSessionQueues([targetResolution.entry.childSessionKey, targetSessionId]);
|
||||
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
|
||||
logVerbose(
|
||||
`subagents steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await callGateway({
|
||||
method: "agent.wait",
|
||||
params: {
|
||||
runId: targetResolution.entry.runId,
|
||||
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS,
|
||||
},
|
||||
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000,
|
||||
});
|
||||
} catch {
|
||||
// Continue even if wait fails; steer should still be attempted.
|
||||
}
|
||||
}
|
||||
|
||||
const idempotencyKey = crypto.randomUUID();
|
||||
let runId: string = idempotencyKey;
|
||||
try {
|
||||
const response = await callGateway<{ runId: string }>({
|
||||
method: "agent",
|
||||
params: {
|
||||
message,
|
||||
sessionKey: targetResolution.entry.childSessionKey,
|
||||
sessionId: targetSessionId,
|
||||
idempotencyKey,
|
||||
deliver: false,
|
||||
channel: INTERNAL_MESSAGE_CHANNEL,
|
||||
lane: AGENT_LANE_SUBAGENT,
|
||||
timeout: 0,
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const responseRunId = typeof response?.runId === "string" ? response.runId : undefined;
|
||||
if (responseRunId) {
|
||||
runId = responseRunId;
|
||||
}
|
||||
} catch (err) {
|
||||
if (steerRequested) {
|
||||
clearSubagentRunSteerRestart(targetResolution.entry.runId);
|
||||
}
|
||||
const messageText =
|
||||
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
|
||||
return stopWithText(`send failed: ${messageText}`);
|
||||
}
|
||||
|
||||
if (steerRequested) {
|
||||
replaceSubagentRunAfterSteer({
|
||||
previousRunId: targetResolution.entry.runId,
|
||||
nextRunId: runId,
|
||||
fallback: targetResolution.entry,
|
||||
runTimeoutSeconds: targetResolution.entry.runTimeoutSeconds ?? 0,
|
||||
});
|
||||
return stopWithText(
|
||||
`steered ${formatRunLabel(targetResolution.entry)} (run ${runId.slice(0, 8)}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const waitMs = 30_000;
|
||||
const wait = await callGateway<{ status?: string; error?: string }>({
|
||||
method: "agent.wait",
|
||||
params: { runId, timeoutMs: waitMs },
|
||||
timeoutMs: waitMs + 2000,
|
||||
});
|
||||
if (wait?.status === "timeout") {
|
||||
return stopWithText(`⏳ Subagent still running (run ${runId.slice(0, 8)}).`);
|
||||
}
|
||||
if (wait?.status === "error") {
|
||||
const waitError = typeof wait.error === "string" ? wait.error : "unknown error";
|
||||
return stopWithText(`⚠️ Subagent error: ${waitError} (run ${runId.slice(0, 8)}).`);
|
||||
}
|
||||
|
||||
const history = await callGateway<{ messages: Array<unknown> }>({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: targetResolution.entry.childSessionKey, limit: 50 },
|
||||
});
|
||||
const filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []);
|
||||
const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
|
||||
const replyText = last ? extractAssistantText(last) : undefined;
|
||||
return stopWithText(
|
||||
replyText ?? `✅ Sent to ${formatRunLabel(targetResolution.entry)} (run ${runId.slice(0, 8)}).`,
|
||||
);
|
||||
}
|
||||
65
src/auto-reply/reply/commands-subagents/action-spawn.ts
Normal file
65
src/auto-reply/reply/commands-subagents/action-spawn.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { spawnSubagentDirect } from "../../../agents/subagent-spawn.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import { type SubagentsCommandContext, stopWithText } from "./shared.js";
|
||||
|
||||
export async function handleSubagentsSpawnAction(
|
||||
ctx: SubagentsCommandContext,
|
||||
): Promise<CommandHandlerResult> {
|
||||
const { params, requesterKey, restTokens } = ctx;
|
||||
const agentId = restTokens[0];
|
||||
|
||||
const taskParts: string[] = [];
|
||||
let model: string | undefined;
|
||||
let thinking: string | undefined;
|
||||
for (let i = 1; i < restTokens.length; i++) {
|
||||
if (restTokens[i] === "--model" && i + 1 < restTokens.length) {
|
||||
i += 1;
|
||||
model = restTokens[i];
|
||||
} else if (restTokens[i] === "--thinking" && i + 1 < restTokens.length) {
|
||||
i += 1;
|
||||
thinking = restTokens[i];
|
||||
} else {
|
||||
taskParts.push(restTokens[i]);
|
||||
}
|
||||
}
|
||||
const task = taskParts.join(" ").trim();
|
||||
if (!agentId || !task) {
|
||||
return stopWithText(
|
||||
"Usage: /subagents spawn <agentId> <task> [--model <model>] [--thinking <level>]",
|
||||
);
|
||||
}
|
||||
|
||||
const commandTo = typeof params.command.to === "string" ? params.command.to.trim() : "";
|
||||
const originatingTo =
|
||||
typeof params.ctx.OriginatingTo === "string" ? params.ctx.OriginatingTo.trim() : "";
|
||||
const fallbackTo = typeof params.ctx.To === "string" ? params.ctx.To.trim() : "";
|
||||
const normalizedTo = originatingTo || commandTo || fallbackTo || undefined;
|
||||
|
||||
const result = await spawnSubagentDirect(
|
||||
{
|
||||
task,
|
||||
agentId,
|
||||
model,
|
||||
thinking,
|
||||
mode: "run",
|
||||
cleanup: "keep",
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{
|
||||
agentSessionKey: requesterKey,
|
||||
agentChannel: params.ctx.OriginatingChannel ?? params.command.channel,
|
||||
agentAccountId: params.ctx.AccountId,
|
||||
agentTo: normalizedTo,
|
||||
agentThreadId: params.ctx.MessageThreadId,
|
||||
agentGroupId: params.sessionEntry?.groupId ?? null,
|
||||
agentGroupChannel: params.sessionEntry?.groupChannel ?? null,
|
||||
agentGroupSpace: params.sessionEntry?.space ?? null,
|
||||
},
|
||||
);
|
||||
if (result.status === "accepted") {
|
||||
return stopWithText(
|
||||
`Spawned subagent ${agentId} (session ${result.childSessionKey}, run ${result.runId?.slice(0, 8)}).`,
|
||||
);
|
||||
}
|
||||
return stopWithText(`Spawn failed: ${result.error ?? result.status}`);
|
||||
}
|
||||
42
src/auto-reply/reply/commands-subagents/action-unfocus.ts
Normal file
42
src/auto-reply/reply/commands-subagents/action-unfocus.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { getThreadBindingManager } from "../../../discord/monitor/thread-bindings.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import {
|
||||
type SubagentsCommandContext,
|
||||
isDiscordSurface,
|
||||
resolveDiscordAccountId,
|
||||
stopWithText,
|
||||
} from "./shared.js";
|
||||
|
||||
export function handleSubagentsUnfocusAction(ctx: SubagentsCommandContext): CommandHandlerResult {
|
||||
const { params } = ctx;
|
||||
if (!isDiscordSurface(params)) {
|
||||
return stopWithText("⚠️ /unfocus is only available on Discord.");
|
||||
}
|
||||
|
||||
const threadId = params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId) : "";
|
||||
if (!threadId.trim()) {
|
||||
return stopWithText("⚠️ /unfocus must be run inside a Discord thread.");
|
||||
}
|
||||
|
||||
const threadBindings = getThreadBindingManager(resolveDiscordAccountId(params));
|
||||
if (!threadBindings) {
|
||||
return stopWithText("⚠️ Discord thread bindings are unavailable for this account.");
|
||||
}
|
||||
|
||||
const binding = threadBindings.getByThreadId(threadId);
|
||||
if (!binding) {
|
||||
return stopWithText("ℹ️ This thread is not currently focused.");
|
||||
}
|
||||
|
||||
const senderId = params.command.senderId?.trim() || "";
|
||||
if (binding.boundBy && binding.boundBy !== "system" && senderId && senderId !== binding.boundBy) {
|
||||
return stopWithText(`⚠️ Only ${binding.boundBy} can unfocus this thread.`);
|
||||
}
|
||||
|
||||
threadBindings.unbindThread({
|
||||
threadId,
|
||||
reason: "manual",
|
||||
sendFarewell: true,
|
||||
});
|
||||
return stopWithText("✅ Thread unfocused.");
|
||||
}
|
||||
432
src/auto-reply/reply/commands-subagents/shared.ts
Normal file
432
src/auto-reply/reply/commands-subagents/shared.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
import type { SubagentRunRecord } from "../../../agents/subagent-registry.js";
|
||||
import {
|
||||
extractAssistantText,
|
||||
resolveInternalSessionKey,
|
||||
resolveMainSessionAlias,
|
||||
sanitizeTextContent,
|
||||
stripToolMessages,
|
||||
} from "../../../agents/tools/sessions-helpers.js";
|
||||
import type {
|
||||
SessionEntry,
|
||||
loadSessionStore as loadSessionStoreFn,
|
||||
resolveStorePath as resolveStorePathFn,
|
||||
} from "../../../config/sessions.js";
|
||||
import { parseDiscordTarget } from "../../../discord/targets.js";
|
||||
import { callGateway } from "../../../gateway/call.js";
|
||||
import { formatTimeAgo } from "../../../infra/format-time/format-relative.ts";
|
||||
import { parseAgentSessionKey } from "../../../routing/session-key.js";
|
||||
import { extractTextFromChatContent } from "../../../shared/chat-content.js";
|
||||
import {
|
||||
formatDurationCompact,
|
||||
formatTokenUsageDisplay,
|
||||
truncateLine,
|
||||
} from "../../../shared/subagents-format.js";
|
||||
import type { CommandHandler, CommandHandlerResult } from "../commands-types.js";
|
||||
import {
|
||||
formatRunLabel,
|
||||
formatRunStatus,
|
||||
resolveSubagentTargetFromRuns,
|
||||
type SubagentTargetResolution,
|
||||
} from "../subagents-utils.js";
|
||||
|
||||
export { extractAssistantText, stripToolMessages };
|
||||
|
||||
export const COMMAND = "/subagents";
|
||||
export const COMMAND_KILL = "/kill";
|
||||
export const COMMAND_STEER = "/steer";
|
||||
export const COMMAND_TELL = "/tell";
|
||||
export const COMMAND_FOCUS = "/focus";
|
||||
export const COMMAND_UNFOCUS = "/unfocus";
|
||||
export const COMMAND_AGENTS = "/agents";
|
||||
export const ACTIONS = new Set([
|
||||
"list",
|
||||
"kill",
|
||||
"log",
|
||||
"send",
|
||||
"steer",
|
||||
"info",
|
||||
"spawn",
|
||||
"focus",
|
||||
"unfocus",
|
||||
"agents",
|
||||
"help",
|
||||
]);
|
||||
|
||||
export const RECENT_WINDOW_MINUTES = 30;
|
||||
const SUBAGENT_TASK_PREVIEW_MAX = 110;
|
||||
export const STEER_ABORT_SETTLE_TIMEOUT_MS = 5_000;
|
||||
|
||||
const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function compactLine(value: string) {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function formatTaskPreview(value: string) {
|
||||
return truncateLine(compactLine(value), SUBAGENT_TASK_PREVIEW_MAX);
|
||||
}
|
||||
|
||||
function resolveModelDisplay(
|
||||
entry?: {
|
||||
model?: unknown;
|
||||
modelProvider?: unknown;
|
||||
modelOverride?: unknown;
|
||||
providerOverride?: unknown;
|
||||
},
|
||||
fallbackModel?: string,
|
||||
) {
|
||||
const model = typeof entry?.model === "string" ? entry.model.trim() : "";
|
||||
const provider = typeof entry?.modelProvider === "string" ? entry.modelProvider.trim() : "";
|
||||
let combined = model.includes("/") ? model : model && provider ? `${provider}/${model}` : model;
|
||||
if (!combined) {
|
||||
const overrideModel =
|
||||
typeof entry?.modelOverride === "string" ? entry.modelOverride.trim() : "";
|
||||
const overrideProvider =
|
||||
typeof entry?.providerOverride === "string" ? entry.providerOverride.trim() : "";
|
||||
combined = overrideModel.includes("/")
|
||||
? overrideModel
|
||||
: overrideModel && overrideProvider
|
||||
? `${overrideProvider}/${overrideModel}`
|
||||
: overrideModel;
|
||||
}
|
||||
if (!combined) {
|
||||
combined = fallbackModel?.trim() || "";
|
||||
}
|
||||
if (!combined) {
|
||||
return "model n/a";
|
||||
}
|
||||
const slash = combined.lastIndexOf("/");
|
||||
if (slash >= 0 && slash < combined.length - 1) {
|
||||
return combined.slice(slash + 1);
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
export function resolveDisplayStatus(entry: SubagentRunRecord) {
|
||||
const status = formatRunStatus(entry);
|
||||
return status === "error" ? "failed" : status;
|
||||
}
|
||||
|
||||
export function formatSubagentListLine(params: {
|
||||
entry: SubagentRunRecord;
|
||||
index: number;
|
||||
runtimeMs: number;
|
||||
sessionEntry?: SessionEntry;
|
||||
}) {
|
||||
const usageText = formatTokenUsageDisplay(params.sessionEntry);
|
||||
const label = truncateLine(formatRunLabel(params.entry, { maxLength: 48 }), 48);
|
||||
const task = formatTaskPreview(params.entry.task);
|
||||
const runtime = formatDurationCompact(params.runtimeMs);
|
||||
const status = resolveDisplayStatus(params.entry);
|
||||
return `${params.index}. ${label} (${resolveModelDisplay(params.sessionEntry, params.entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`;
|
||||
}
|
||||
|
||||
function formatTimestamp(valueMs?: number) {
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
||||
return "n/a";
|
||||
}
|
||||
return new Date(valueMs).toISOString();
|
||||
}
|
||||
|
||||
export function formatTimestampWithAge(valueMs?: number) {
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
||||
return "n/a";
|
||||
}
|
||||
return `${formatTimestamp(valueMs)} (${formatTimeAgo(Date.now() - valueMs, { fallback: "n/a" })})`;
|
||||
}
|
||||
|
||||
export type SubagentsAction =
|
||||
| "list"
|
||||
| "kill"
|
||||
| "log"
|
||||
| "send"
|
||||
| "steer"
|
||||
| "info"
|
||||
| "spawn"
|
||||
| "focus"
|
||||
| "unfocus"
|
||||
| "agents"
|
||||
| "help";
|
||||
|
||||
export type SubagentsCommandParams = Parameters<CommandHandler>[0];
|
||||
|
||||
export type SubagentsCommandContext = {
|
||||
params: SubagentsCommandParams;
|
||||
handledPrefix: string;
|
||||
requesterKey: string;
|
||||
runs: SubagentRunRecord[];
|
||||
restTokens: string[];
|
||||
};
|
||||
|
||||
export function stopWithText(text: string): CommandHandlerResult {
|
||||
return { shouldContinue: false, reply: { text } };
|
||||
}
|
||||
|
||||
export function stopWithUnknownTargetError(error?: string): CommandHandlerResult {
|
||||
return stopWithText(`⚠️ ${error ?? "Unknown subagent."}`);
|
||||
}
|
||||
|
||||
export function resolveSubagentTarget(
|
||||
runs: SubagentRunRecord[],
|
||||
token: string | undefined,
|
||||
): SubagentTargetResolution {
|
||||
return resolveSubagentTargetFromRuns({
|
||||
runs,
|
||||
token,
|
||||
recentWindowMinutes: RECENT_WINDOW_MINUTES,
|
||||
label: (entry) => formatRunLabel(entry),
|
||||
errors: {
|
||||
missingTarget: "Missing subagent id.",
|
||||
invalidIndex: (value) => `Invalid subagent index: ${value}`,
|
||||
unknownSession: (value) => `Unknown subagent session: ${value}`,
|
||||
ambiguousLabel: (value) => `Ambiguous subagent label: ${value}`,
|
||||
ambiguousLabelPrefix: (value) => `Ambiguous subagent label prefix: ${value}`,
|
||||
ambiguousRunIdPrefix: (value) => `Ambiguous run id prefix: ${value}`,
|
||||
unknownTarget: (value) => `Unknown subagent id: ${value}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveSubagentEntryForToken(
|
||||
runs: SubagentRunRecord[],
|
||||
token: string | undefined,
|
||||
): { entry: SubagentRunRecord } | { reply: CommandHandlerResult } {
|
||||
const resolved = resolveSubagentTarget(runs, token);
|
||||
if (!resolved.entry) {
|
||||
return { reply: stopWithUnknownTargetError(resolved.error) };
|
||||
}
|
||||
return { entry: resolved.entry };
|
||||
}
|
||||
|
||||
export function resolveRequesterSessionKey(
|
||||
params: SubagentsCommandParams,
|
||||
opts?: { preferCommandTarget?: boolean },
|
||||
): string | undefined {
|
||||
const commandTarget = params.ctx.CommandTargetSessionKey?.trim();
|
||||
const commandSession = params.sessionKey?.trim();
|
||||
const raw = opts?.preferCommandTarget
|
||||
? commandTarget || commandSession
|
||||
: commandSession || commandTarget;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
|
||||
return resolveInternalSessionKey({ key: raw, alias, mainKey });
|
||||
}
|
||||
|
||||
export function resolveHandledPrefix(normalized: string): string | null {
|
||||
return normalized.startsWith(COMMAND)
|
||||
? COMMAND
|
||||
: normalized.startsWith(COMMAND_KILL)
|
||||
? COMMAND_KILL
|
||||
: normalized.startsWith(COMMAND_STEER)
|
||||
? COMMAND_STEER
|
||||
: normalized.startsWith(COMMAND_TELL)
|
||||
? COMMAND_TELL
|
||||
: normalized.startsWith(COMMAND_FOCUS)
|
||||
? COMMAND_FOCUS
|
||||
: normalized.startsWith(COMMAND_UNFOCUS)
|
||||
? COMMAND_UNFOCUS
|
||||
: normalized.startsWith(COMMAND_AGENTS)
|
||||
? COMMAND_AGENTS
|
||||
: null;
|
||||
}
|
||||
|
||||
export function resolveSubagentsAction(params: {
|
||||
handledPrefix: string;
|
||||
restTokens: string[];
|
||||
}): SubagentsAction | null {
|
||||
if (params.handledPrefix === COMMAND) {
|
||||
const [actionRaw] = params.restTokens;
|
||||
const action = (actionRaw?.toLowerCase() || "list") as SubagentsAction;
|
||||
if (!ACTIONS.has(action)) {
|
||||
return null;
|
||||
}
|
||||
params.restTokens.splice(0, 1);
|
||||
return action;
|
||||
}
|
||||
if (params.handledPrefix === COMMAND_KILL) {
|
||||
return "kill";
|
||||
}
|
||||
if (params.handledPrefix === COMMAND_FOCUS) {
|
||||
return "focus";
|
||||
}
|
||||
if (params.handledPrefix === COMMAND_UNFOCUS) {
|
||||
return "unfocus";
|
||||
}
|
||||
if (params.handledPrefix === COMMAND_AGENTS) {
|
||||
return "agents";
|
||||
}
|
||||
return "steer";
|
||||
}
|
||||
|
||||
export type FocusTargetResolution = {
|
||||
targetKind: "subagent" | "acp";
|
||||
targetSessionKey: string;
|
||||
agentId: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function isDiscordSurface(params: SubagentsCommandParams): boolean {
|
||||
const channel =
|
||||
params.ctx.OriginatingChannel ??
|
||||
params.command.channel ??
|
||||
params.ctx.Surface ??
|
||||
params.ctx.Provider;
|
||||
return (
|
||||
String(channel ?? "")
|
||||
.trim()
|
||||
.toLowerCase() === "discord"
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDiscordAccountId(params: SubagentsCommandParams): string {
|
||||
const accountId = typeof params.ctx.AccountId === "string" ? params.ctx.AccountId.trim() : "";
|
||||
return accountId || "default";
|
||||
}
|
||||
|
||||
export function resolveDiscordChannelIdForFocus(
|
||||
params: SubagentsCommandParams,
|
||||
): string | undefined {
|
||||
const toCandidates = [
|
||||
typeof params.ctx.OriginatingTo === "string" ? params.ctx.OriginatingTo.trim() : "",
|
||||
typeof params.command.to === "string" ? params.command.to.trim() : "",
|
||||
typeof params.ctx.To === "string" ? params.ctx.To.trim() : "",
|
||||
].filter(Boolean);
|
||||
for (const candidate of toCandidates) {
|
||||
try {
|
||||
const target = parseDiscordTarget(candidate, { defaultKind: "channel" });
|
||||
if (target?.kind === "channel" && target.id) {
|
||||
return target.id;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse failures and try the next candidate.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function resolveFocusTargetSession(params: {
|
||||
runs: SubagentRunRecord[];
|
||||
token: string;
|
||||
}): Promise<FocusTargetResolution | null> {
|
||||
const subagentMatch = resolveSubagentTarget(params.runs, params.token);
|
||||
if (subagentMatch.entry) {
|
||||
const key = subagentMatch.entry.childSessionKey;
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
return {
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: key,
|
||||
agentId: parsed?.agentId ?? "main",
|
||||
label: formatRunLabel(subagentMatch.entry),
|
||||
};
|
||||
}
|
||||
|
||||
const token = params.token.trim();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attempts: Array<Record<string, string>> = [];
|
||||
attempts.push({ key: token });
|
||||
if (SESSION_ID_RE.test(token)) {
|
||||
attempts.push({ sessionId: token });
|
||||
}
|
||||
attempts.push({ label: token });
|
||||
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
const resolved = await callGateway<{ key?: string }>({
|
||||
method: "sessions.resolve",
|
||||
params: attempt,
|
||||
});
|
||||
const key = typeof resolved?.key === "string" ? resolved.key.trim() : "";
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
return {
|
||||
targetKind: key.includes(":subagent:") ? "subagent" : "acp",
|
||||
targetSessionKey: key,
|
||||
agentId: parsed?.agentId ?? "main",
|
||||
label: token,
|
||||
};
|
||||
} catch {
|
||||
// Try the next resolution strategy.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildSubagentsHelp() {
|
||||
return [
|
||||
"Subagents",
|
||||
"Usage:",
|
||||
"- /subagents list",
|
||||
"- /subagents kill <id|#|all>",
|
||||
"- /subagents log <id|#> [limit] [tools]",
|
||||
"- /subagents info <id|#>",
|
||||
"- /subagents send <id|#> <message>",
|
||||
"- /subagents steer <id|#> <message>",
|
||||
"- /subagents spawn <agentId> <task> [--model <model>] [--thinking <level>]",
|
||||
"- /focus <subagent-label|session-key|session-id|session-label>",
|
||||
"- /unfocus",
|
||||
"- /agents",
|
||||
"- /session ttl <duration|off>",
|
||||
"- /kill <id|#|all>",
|
||||
"- /steer <id|#> <message>",
|
||||
"- /tell <id|#> <message>",
|
||||
"",
|
||||
"Ids: use the list index (#), runId/session prefix, label, or full session key.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export type ChatMessage = {
|
||||
role?: unknown;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
export function extractMessageText(message: ChatMessage): { role: string; text: string } | null {
|
||||
const role = typeof message.role === "string" ? message.role : "";
|
||||
const shouldSanitize = role === "assistant";
|
||||
const text = extractTextFromChatContent(message.content, {
|
||||
sanitizeText: shouldSanitize ? sanitizeTextContent : undefined,
|
||||
});
|
||||
return text ? { role, text } : null;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export type SessionStoreCache = Map<string, Record<string, SessionEntry>>;
|
||||
|
||||
export function loadSubagentSessionEntry(
|
||||
params: SubagentsCommandParams,
|
||||
childKey: string,
|
||||
loaders: {
|
||||
loadSessionStore: typeof loadSessionStoreFn;
|
||||
resolveStorePath: typeof resolveStorePathFn;
|
||||
},
|
||||
storeCache?: SessionStoreCache,
|
||||
) {
|
||||
const parsed = parseAgentSessionKey(childKey);
|
||||
const storePath = loaders.resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: parsed?.agentId,
|
||||
});
|
||||
let store = storeCache?.get(storePath);
|
||||
if (!store) {
|
||||
store = loaders.loadSessionStore(storePath);
|
||||
storeCache?.set(storePath, store);
|
||||
}
|
||||
return { storePath, store, entry: store[childKey] };
|
||||
}
|
||||
Reference in New Issue
Block a user