mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
The status-bar "Agents" item conflated three unrelated signals — running subagents (aggregated across all sessions), in-flight session turns, and failed background *system* actions (gateway restarts, toolset installs, computer-use grants via $desktopActionTasks/preview restart) — yet clicking it opens AgentsView, which renders only subagents. A failed gateway restart therefore showed "Agents (1 Failed)" over an empty "No live subagents" tree. AgentsView also filtered to the active session, so a subagent running in a background session showed "Agents N running" with nothing in the tree (the desync reported in #49808). Unify the scope both surfaces speak: - AgentsView aggregates subagents across every session (salvages #49819). - The indicator's running/failed counts come from subagents only (aggregated), never background system actions — those keep their own surfaces in settings / command center. So "Agents (N …)" now always points at a populated Spawn tree. Supersedes #49819. Fixes #49808.
270 lines
8.2 KiB
TypeScript
270 lines
8.2 KiB
TypeScript
import { atom } from 'nanostores'
|
|
|
|
export type SubagentStatus = 'completed' | 'failed' | 'interrupted' | 'queued' | 'running'
|
|
export type SubagentStreamKind = 'progress' | 'summary' | 'thinking' | 'tool'
|
|
|
|
export interface SubagentStreamEntry {
|
|
at: number
|
|
isError?: boolean
|
|
kind: SubagentStreamKind
|
|
text: string
|
|
}
|
|
|
|
export interface SubagentProgress {
|
|
id: string
|
|
parentId: null | string
|
|
goal: string
|
|
/** The child's own stored session id — lets UIs open its session window. */
|
|
sessionId?: string
|
|
model?: string
|
|
status: SubagentStatus
|
|
taskCount: number
|
|
taskIndex: number
|
|
startedAt: number
|
|
updatedAt: number
|
|
durationSeconds?: number
|
|
costUsd?: number
|
|
inputTokens?: number
|
|
outputTokens?: number
|
|
toolCount?: number
|
|
filesRead: string[]
|
|
filesWritten: string[]
|
|
stream: SubagentStreamEntry[]
|
|
summary?: string
|
|
/** Active tool while running — cleared on terminal status. */
|
|
currentTool?: string
|
|
}
|
|
|
|
export interface SubagentNode extends SubagentProgress {
|
|
children: SubagentNode[]
|
|
}
|
|
|
|
export type SubagentPayload = Record<string, unknown>
|
|
|
|
const TERMINAL: ReadonlySet<SubagentStatus> = new Set(['completed', 'failed', 'interrupted'])
|
|
const MAX_STREAM = 24
|
|
const PREVIEW_MAX = 220
|
|
const TOOL_PREVIEW_MAX = 96
|
|
|
|
export const $subagentsBySession = atom<Record<string, SubagentProgress[]>>({})
|
|
|
|
const isStr = (v: unknown): v is string => typeof v === 'string'
|
|
const str = (v: unknown) => (isStr(v) ? v : '')
|
|
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
|
|
const strList = (v: unknown) => (Array.isArray(v) ? v.filter(isStr) : [])
|
|
|
|
const asStatus = (v: unknown): SubagentStatus =>
|
|
v === 'completed' || v === 'failed' || v === 'interrupted' || v === 'queued' ? v : 'running'
|
|
|
|
const compact = (text: string, max = PREVIEW_MAX) => {
|
|
const line = text.replace(/\s+/g, ' ').trim()
|
|
|
|
if (!line) {
|
|
return ''
|
|
}
|
|
|
|
return line.length > max ? `${line.slice(0, max - 1)}…` : line
|
|
}
|
|
|
|
const toolLabel = (name: string) =>
|
|
name
|
|
.split('_')
|
|
.filter(Boolean)
|
|
.map(p => p[0]!.toUpperCase() + p.slice(1))
|
|
.join(' ') || name
|
|
|
|
const formatTool = (name: string, preview = '') => {
|
|
const snippet = compact(preview, TOOL_PREVIEW_MAX)
|
|
|
|
return snippet ? `${toolLabel(name)}("${snippet}")` : toolLabel(name)
|
|
}
|
|
|
|
interface TailEntry {
|
|
isError?: boolean
|
|
preview?: string
|
|
tool?: string
|
|
}
|
|
|
|
const asTail = (v: unknown): TailEntry[] =>
|
|
Array.isArray(v)
|
|
? v
|
|
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
|
|
.map(item => ({
|
|
isError: item.is_error === true,
|
|
preview: str(item.preview) || undefined,
|
|
tool: str(item.tool) || undefined
|
|
}))
|
|
: []
|
|
|
|
const idOf = (p: SubagentPayload) =>
|
|
str(p.subagent_id) || `${str(p.parent_id) || 'root'}:${num(p.task_index) ?? 0}:${str(p.goal)}`
|
|
|
|
const appendStream = (stream: SubagentStreamEntry[], entry: SubagentStreamEntry) => {
|
|
const last = stream.at(-1)
|
|
|
|
if (last?.kind === entry.kind && last.text === entry.text && last.isError === entry.isError) {
|
|
return stream
|
|
}
|
|
|
|
return [...stream, entry].slice(-MAX_STREAM)
|
|
}
|
|
|
|
function streamFromPayload(
|
|
payload: SubagentPayload,
|
|
status: SubagentStatus,
|
|
eventType: string,
|
|
at: number
|
|
): SubagentStreamEntry[] {
|
|
const out: SubagentStreamEntry[] = []
|
|
const tool = str(payload.tool_name)
|
|
const preview = str(payload.tool_preview) || str(payload.text)
|
|
const text = compact(str(payload.text) || preview)
|
|
|
|
for (const tail of asTail(payload.output_tail)) {
|
|
const line = tail.tool ? formatTool(tail.tool, tail.preview ?? '') : compact(tail.preview ?? '')
|
|
|
|
if (line) {
|
|
out.push({ at, isError: tail.isError, kind: tail.tool ? 'tool' : 'progress', text: line })
|
|
}
|
|
}
|
|
|
|
if (tool) {
|
|
out.push({ at, isError: !!payload.error, kind: 'tool', text: formatTool(tool, preview) })
|
|
}
|
|
|
|
if (eventType === 'subagent.progress' && text) {
|
|
out.push({ at, isError: !!payload.error, kind: 'progress', text })
|
|
}
|
|
|
|
if (eventType === 'subagent.thinking' && text) {
|
|
out.push({ at, kind: 'thinking', text })
|
|
}
|
|
|
|
const summary = compact(str(payload.summary) || str(payload.text))
|
|
|
|
if (TERMINAL.has(status) && summary) {
|
|
out.push({ at, isError: status === 'failed', kind: 'summary', text: summary })
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
function toProgress(payload: SubagentPayload, prev: SubagentProgress | undefined, eventType = ''): SubagentProgress {
|
|
const at = Date.now()
|
|
const status = asStatus(payload.status)
|
|
const tool = str(payload.tool_name)
|
|
const stream = streamFromPayload(payload, status, eventType, at).reduce(appendStream, prev?.stream ?? [])
|
|
const filesRead = strList(payload.files_read)
|
|
const filesWritten = strList(payload.files_written)
|
|
|
|
return {
|
|
id: prev?.id ?? idOf(payload),
|
|
parentId: str(payload.parent_id) || prev?.parentId || null,
|
|
goal: str(payload.goal) || prev?.goal || 'Subagent',
|
|
sessionId: str(payload.child_session_id) || prev?.sessionId,
|
|
model: str(payload.model) || prev?.model,
|
|
status,
|
|
taskCount: num(payload.task_count) ?? prev?.taskCount ?? 1,
|
|
taskIndex: num(payload.task_index) ?? prev?.taskIndex ?? 0,
|
|
startedAt: prev?.startedAt ?? at,
|
|
updatedAt: at,
|
|
durationSeconds: num(payload.duration_seconds) ?? prev?.durationSeconds,
|
|
costUsd: num(payload.cost_usd) ?? prev?.costUsd,
|
|
inputTokens: num(payload.input_tokens) ?? prev?.inputTokens,
|
|
outputTokens: num(payload.output_tokens) ?? prev?.outputTokens,
|
|
toolCount: num(payload.tool_count) ?? prev?.toolCount,
|
|
filesRead: filesRead.length ? filesRead : (prev?.filesRead ?? []),
|
|
filesWritten: filesWritten.length ? filesWritten : (prev?.filesWritten ?? []),
|
|
stream,
|
|
summary: str(payload.summary) || prev?.summary,
|
|
currentTool: TERMINAL.has(status) ? undefined : tool || prev?.currentTool
|
|
}
|
|
}
|
|
|
|
export function clearSessionSubagents(sid: string) {
|
|
const map = $subagentsBySession.get()
|
|
|
|
if (!(sid in map)) {
|
|
return
|
|
}
|
|
|
|
const { [sid]: _drop, ...rest } = map
|
|
$subagentsBySession.set(rest)
|
|
}
|
|
|
|
export function pruneDelegateFallbackSubagents(sid: string) {
|
|
const map = $subagentsBySession.get()
|
|
const list = map[sid]
|
|
|
|
if (!list?.length) {
|
|
return
|
|
}
|
|
|
|
const next = list.filter(item => !item.id.startsWith('delegate-tool:'))
|
|
|
|
if (next.length === list.length) {
|
|
return
|
|
}
|
|
|
|
$subagentsBySession.set({ ...map, [sid]: next })
|
|
}
|
|
|
|
export function upsertSubagent(sid: string, payload: SubagentPayload, createIfMissing = true, eventType?: string) {
|
|
const map = $subagentsBySession.get()
|
|
const list = map[sid] ?? []
|
|
const id = idOf(payload)
|
|
const idx = list.findIndex(item => item.id === id)
|
|
|
|
if (idx < 0 && !createIfMissing) {
|
|
return
|
|
}
|
|
|
|
const prev = idx >= 0 ? list[idx] : undefined
|
|
|
|
if (prev && TERMINAL.has(prev.status)) {
|
|
return
|
|
}
|
|
|
|
const next = toProgress(payload, prev, eventType)
|
|
const nextList = idx >= 0 ? list.map(item => (item.id === id ? next : item)) : [...list, next]
|
|
|
|
$subagentsBySession.set({ ...map, [sid]: nextList })
|
|
}
|
|
|
|
export function buildSubagentTree(items: readonly SubagentProgress[]): SubagentNode[] {
|
|
const nodes = new Map<string, SubagentNode>()
|
|
|
|
for (const item of items) {
|
|
nodes.set(item.id, { ...item, children: [] })
|
|
}
|
|
|
|
const roots: SubagentNode[] = []
|
|
|
|
for (const node of nodes.values()) {
|
|
const parent = node.parentId ? nodes.get(node.parentId) : null
|
|
|
|
if (parent) {
|
|
parent.children.push(node)
|
|
} else {
|
|
roots.push(node)
|
|
}
|
|
}
|
|
|
|
const sort = (a: SubagentNode, b: SubagentNode) =>
|
|
a.startedAt - b.startedAt || a.taskIndex - b.taskIndex || a.goal.localeCompare(b.goal)
|
|
|
|
const walk = (node: SubagentNode) => node.children.sort(sort).forEach(walk)
|
|
roots.sort(sort).forEach(walk)
|
|
|
|
return roots
|
|
}
|
|
|
|
export const activeSubagentCount = (items: readonly SubagentProgress[]) =>
|
|
items.filter(item => item.status === 'queued' || item.status === 'running').length
|
|
|
|
export const failedSubagentCount = (items: readonly SubagentProgress[]) =>
|
|
items.filter(item => item.status === 'failed' || item.status === 'interrupted').length
|
|
|
|
/** Flatten every session's subagents — the scope the Spawn-tree panel and the
|
|
* status-bar indicator must agree on. */
|
|
export const allSubagents = (bySession: Record<string, SubagentProgress[]>) => Object.values(bySession).flat()
|