Files
openclaw/src/agents/subagent-list.ts
2026-04-07 17:50:37 +01:00

279 lines
9.1 KiB
TypeScript

import { resolveSubagentLabel, sortSubagentRuns } from "../auto-reply/reply/subagents-utils.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStorePath } from "../config/sessions/paths.js";
import { loadSessionStore } from "../config/sessions/store-load.js";
import type { SessionEntry } from "../config/sessions/types.js";
import { parseAgentSessionKey, type ParsedAgentSessionKey } from "../routing/session-key.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import {
formatDurationCompact,
formatTokenUsageDisplay,
resolveTotalTokens,
truncateLine,
} from "../shared/subagents-format.js";
import { resolveModelDisplayName, resolveModelDisplayRef } from "./model-selection-display.js";
import { subagentRuns } from "./subagent-registry-memory.js";
import { countPendingDescendantRunsFromRuns } from "./subagent-registry-queries.js";
import {
getSubagentSessionRuntimeMs,
getSubagentSessionStartedAt,
} from "./subagent-registry-read.js";
import { getSubagentRunsSnapshotForRead } from "./subagent-registry-state.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
export type SubagentListItem = {
index: number;
line: string;
runId: string;
sessionKey: string;
label: string;
task: string;
status: string;
pendingDescendants: number;
runtime: string;
runtimeMs: number;
childSessions?: string[];
model?: string;
totalTokens?: number;
startedAt?: number;
endedAt?: number;
};
export type BuiltSubagentList = {
total: number;
active: SubagentListItem[];
recent: SubagentListItem[];
text: string;
};
export type SessionEntryResolution = {
storePath: string;
entry: SessionEntry | undefined;
};
function resolveStorePathForKey(
cfg: OpenClawConfig,
key: string,
parsed?: ParsedAgentSessionKey | null,
) {
return resolveStorePath(cfg.session?.store, {
agentId: parsed?.agentId,
});
}
export function resolveSessionEntryForKey(params: {
cfg: OpenClawConfig;
key: string;
cache: Map<string, Record<string, SessionEntry>>;
}): SessionEntryResolution {
const parsed = parseAgentSessionKey(params.key);
const storePath = resolveStorePathForKey(params.cfg, params.key, parsed);
let store = params.cache.get(storePath);
if (!store) {
store = loadSessionStore(storePath);
params.cache.set(storePath, store);
}
return {
storePath,
entry: store[params.key],
};
}
export function buildLatestSubagentRunIndex(runs: Map<string, SubagentRunRecord>) {
const latestByChildSessionKey = new Map<string, SubagentRunRecord>();
for (const entry of runs.values()) {
const childSessionKey = entry.childSessionKey?.trim();
if (!childSessionKey) {
continue;
}
const existing = latestByChildSessionKey.get(childSessionKey);
if (!existing || entry.createdAt > existing.createdAt) {
latestByChildSessionKey.set(childSessionKey, entry);
}
}
const childSessionsByController = new Map<string, string[]>();
for (const [childSessionKey, entry] of latestByChildSessionKey.entries()) {
const controllerSessionKey =
entry.controllerSessionKey?.trim() || entry.requesterSessionKey?.trim();
if (!controllerSessionKey) {
continue;
}
const existing = childSessionsByController.get(controllerSessionKey);
if (existing) {
existing.push(childSessionKey);
continue;
}
childSessionsByController.set(controllerSessionKey, [childSessionKey]);
}
for (const childSessions of childSessionsByController.values()) {
childSessions.sort();
}
return {
latestByChildSessionKey,
childSessionsByController,
};
}
export function createPendingDescendantCounter(runsSnapshot?: Map<string, SubagentRunRecord>) {
const pendingDescendantCache = new Map<string, number>();
return (sessionKey: string) => {
if (pendingDescendantCache.has(sessionKey)) {
return pendingDescendantCache.get(sessionKey) ?? 0;
}
const snapshot = runsSnapshot ?? getSubagentRunsSnapshotForRead(subagentRuns);
const pending = Math.max(0, countPendingDescendantRunsFromRuns(snapshot, sessionKey));
pendingDescendantCache.set(sessionKey, pending);
return pending;
};
}
export function isActiveSubagentRun(
entry: SubagentRunRecord,
pendingDescendantCount: (sessionKey: string) => number,
) {
return !entry.endedAt || pendingDescendantCount(entry.childSessionKey) > 0;
}
function resolveRunStatus(entry: SubagentRunRecord, options?: { pendingDescendants?: number }) {
const pendingDescendants = Math.max(0, options?.pendingDescendants ?? 0);
if (pendingDescendants > 0) {
const childLabel = pendingDescendants === 1 ? "child" : "children";
return `active (waiting on ${pendingDescendants} ${childLabel})`;
}
if (!entry.endedAt) {
return "running";
}
const status = entry.outcome?.status ?? "done";
if (status === "ok") {
return "done";
}
if (status === "error") {
return "failed";
}
return status;
}
function resolveModelRef(entry?: SessionEntry, fallbackModel?: string) {
return resolveModelDisplayRef({
runtimeProvider: entry?.modelProvider,
runtimeModel: entry?.model,
overrideProvider: entry?.providerOverride,
overrideModel: entry?.modelOverride,
fallbackModel,
});
}
function resolveModelDisplay(entry?: SessionEntry, fallbackModel?: string) {
return resolveModelDisplayName({
runtimeProvider: entry?.modelProvider,
runtimeModel: entry?.model,
overrideProvider: entry?.providerOverride,
overrideModel: entry?.modelOverride,
fallbackModel,
});
}
function buildListText(params: {
active: Array<{ line: string }>;
recent: Array<{ line: string }>;
recentMinutes: number;
}) {
const lines: string[] = [];
lines.push("active subagents:");
if (params.active.length === 0) {
lines.push("(none)");
} else {
lines.push(...params.active.map((entry) => entry.line));
}
lines.push("");
lines.push(`recent (last ${params.recentMinutes}m):`);
if (params.recent.length === 0) {
lines.push("(none)");
} else {
lines.push(...params.recent.map((entry) => entry.line));
}
return lines.join("\n");
}
export function buildSubagentList(params: {
cfg: OpenClawConfig;
runs: SubagentRunRecord[];
recentMinutes: number;
taskMaxChars?: number;
}): BuiltSubagentList {
const now = Date.now();
const recentCutoff = now - params.recentMinutes * 60_000;
const dedupedRuns: SubagentRunRecord[] = [];
const seenChildSessionKeys = new Set<string>();
for (const entry of sortSubagentRuns(params.runs)) {
if (seenChildSessionKeys.has(entry.childSessionKey)) {
continue;
}
seenChildSessionKeys.add(entry.childSessionKey);
dedupedRuns.push(entry);
}
const cache = new Map<string, Record<string, SessionEntry>>();
const snapshot = getSubagentRunsSnapshotForRead(subagentRuns);
const { childSessionsByController } = buildLatestSubagentRunIndex(snapshot);
const pendingDescendantCount = createPendingDescendantCounter(snapshot);
let index = 1;
const buildListEntry = (entry: SubagentRunRecord, runtimeMs: number) => {
const sessionEntry = resolveSessionEntryForKey({
cfg: params.cfg,
key: entry.childSessionKey,
cache,
}).entry;
const totalTokens = resolveTotalTokens(sessionEntry);
const usageText = formatTokenUsageDisplay(sessionEntry);
const pendingDescendants = pendingDescendantCount(entry.childSessionKey);
const status = resolveRunStatus(entry, {
pendingDescendants,
});
const childSessions = childSessionsByController.get(entry.childSessionKey) ?? [];
const runtime = formatDurationCompact(runtimeMs) ?? "n/a";
const label = truncateLine(resolveSubagentLabel(entry), 48);
const task = truncateLine(entry.task.trim(), params.taskMaxChars ?? 72);
const line = `${index}. ${label} (${resolveModelDisplay(sessionEntry, entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${normalizeLowercaseStringOrEmpty(task) !== normalizeLowercaseStringOrEmpty(label) ? ` - ${task}` : ""}`;
const view: SubagentListItem = {
index,
line,
runId: entry.runId,
sessionKey: entry.childSessionKey,
label,
task,
status,
pendingDescendants,
runtime,
runtimeMs,
...(childSessions.length > 0 ? { childSessions } : {}),
model: resolveModelRef(sessionEntry, entry.model),
totalTokens,
startedAt: getSubagentSessionStartedAt(entry),
...(entry.endedAt ? { endedAt: entry.endedAt } : {}),
};
index += 1;
return view;
};
const active = dedupedRuns
.filter((entry) => isActiveSubagentRun(entry, pendingDescendantCount))
.map((entry) => buildListEntry(entry, getSubagentSessionRuntimeMs(entry, now) ?? 0));
const recent = dedupedRuns
.filter(
(entry) =>
!isActiveSubagentRun(entry, pendingDescendantCount) &&
!!entry.endedAt &&
(entry.endedAt ?? 0) >= recentCutoff,
)
.map((entry) =>
buildListEntry(entry, getSubagentSessionRuntimeMs(entry, entry.endedAt ?? now) ?? 0),
);
return {
total: dedupedRuns.length,
active,
recent,
text: buildListText({ active, recent, recentMinutes: params.recentMinutes }),
};
}