mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-01 01:51:44 +00:00
feat(tui): subagent spawn observability overlay
Adds a live + post-hoc audit surface for recursive delegate_task fan-out. None of cc/oc/oclaw tackle nested subagent trees inside an Ink overlay; this ships a view-switched dashboard that handles arbitrary depth + width. Python - delegate_tool: every subagent event now carries subagent_id, parent_id, depth, model, tool_count; subagent.complete also ships input/output/ reasoning tokens, cost, api_calls, files_read/files_written, and a tail of tool-call outputs - delegate_tool: new subagent.spawn_requested event + _active_subagents registry so the overlay can kill a branch by id and pause new spawns - tui_gateway: new RPCs delegation.status, delegation.pause, subagent.interrupt, spawn_tree.save/list/load (disk under \$HERMES_HOME/spawn-trees/<session>/<ts>.json) TUI - /agents overlay: full-width list mode (gantt strip + row picker) and Enter-to-drill full-width scrollable detail mode; inverse+amber selection, heat-coloured branch markers, wall-clock gantt with tick ruler, per-branch rollups - Detail pane: collapsible accordions (Budget, Files, Tool calls, Output, Progress, Summary); open-state persists across agents + mode switches via a shared atom - /replay [N|last|list|load <path>] for in-memory + disk history; /replay-diff <a> <b> for side-by-side tree comparison - Status-bar SpawnHud warns as depth/concurrency approaches caps; overlay auto-follows the just-finished turn onto history[1] - Theme: bump DARK dim #B8860B → #CC9B1F for readable secondary text globally; keep LIGHT untouched Tests: +29 new subagentTree unit tests; 215/215 passing.
This commit is contained in:
parent
ba7e8b0df9
commit
7785654ad5
19 changed files with 4329 additions and 426 deletions
139
ui-tui/src/app/spawnHistoryStore.ts
Normal file
139
ui-tui/src/app/spawnHistoryStore.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
import type { SpawnTreeLoadResponse } from '../gatewayTypes.js'
|
||||
import type { SubagentProgress } from '../types.js'
|
||||
|
||||
export interface SpawnSnapshot {
|
||||
finishedAt: number
|
||||
fromDisk?: boolean
|
||||
id: string
|
||||
label: string
|
||||
path?: string
|
||||
sessionId: null | string
|
||||
startedAt: number
|
||||
subagents: SubagentProgress[]
|
||||
}
|
||||
|
||||
export interface SpawnDiffPair {
|
||||
baseline: SpawnSnapshot
|
||||
candidate: SpawnSnapshot
|
||||
}
|
||||
|
||||
const HISTORY_LIMIT = 10
|
||||
|
||||
export const $spawnHistory = atom<SpawnSnapshot[]>([])
|
||||
export const $spawnDiff = atom<null | SpawnDiffPair>(null)
|
||||
|
||||
export const getSpawnHistory = () => $spawnHistory.get()
|
||||
export const getSpawnDiff = () => $spawnDiff.get()
|
||||
|
||||
export const clearSpawnHistory = () => $spawnHistory.set([])
|
||||
export const clearDiffPair = () => $spawnDiff.set(null)
|
||||
export const setDiffPair = (pair: SpawnDiffPair) => $spawnDiff.set(pair)
|
||||
|
||||
/**
|
||||
* Commit a finished turn's spawn tree to history. Keeps the last 10
|
||||
* non-empty snapshots — empty turns (no subagents) are dropped.
|
||||
*
|
||||
* Why in-memory? The primary investigation loop is "I just ran a fan-out,
|
||||
* it misbehaved, let me look at what happened" — same-session debugging.
|
||||
* Disk persistence across process restarts is a natural extension but
|
||||
* adds RPC surface for a less-common path.
|
||||
*/
|
||||
export const pushSnapshot = (
|
||||
subagents: readonly SubagentProgress[],
|
||||
meta: { sessionId?: null | string; startedAt?: null | number }
|
||||
) => {
|
||||
if (!subagents.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const started = meta.startedAt ?? Math.min(...subagents.map(s => s.startedAt ?? now))
|
||||
|
||||
const snap: SpawnSnapshot = {
|
||||
finishedAt: now,
|
||||
id: `snap-${now.toString(36)}`,
|
||||
label: summarizeLabel(subagents),
|
||||
sessionId: meta.sessionId ?? null,
|
||||
startedAt: Number.isFinite(started) ? started : now,
|
||||
subagents: subagents.map(item => ({ ...item }))
|
||||
}
|
||||
|
||||
const next = [snap, ...$spawnHistory.get()].slice(0, HISTORY_LIMIT)
|
||||
$spawnHistory.set(next)
|
||||
}
|
||||
|
||||
function summarizeLabel(subagents: readonly SubagentProgress[]): string {
|
||||
const top = subagents
|
||||
.filter(s => s.parentId == null || subagents.every(o => o.id !== s.parentId))
|
||||
.slice(0, 2)
|
||||
.map(s => s.goal || 'subagent')
|
||||
.join(' · ')
|
||||
|
||||
return top || `${subagents.length} agent${subagents.length === 1 ? '' : 's'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a disk-loaded snapshot onto the front of the history stack so the
|
||||
* overlay can pick it up at index 1 via /replay load. Normalises the
|
||||
* server payload (arbitrary list) into the same SubagentProgress shape
|
||||
* used for live data — defensive against cross-version reads.
|
||||
*/
|
||||
export const pushDiskSnapshot = (r: SpawnTreeLoadResponse, path: string) => {
|
||||
const raw = Array.isArray(r.subagents) ? r.subagents : []
|
||||
const normalised = raw.map(normaliseSubagent)
|
||||
|
||||
if (!normalised.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const snap: SpawnSnapshot = {
|
||||
finishedAt: (r.finished_at ?? Date.now() / 1000) * 1000,
|
||||
fromDisk: true,
|
||||
id: `disk-${path}`,
|
||||
label: r.label || `${normalised.length} subagents`,
|
||||
path,
|
||||
sessionId: r.session_id ?? null,
|
||||
startedAt: (r.started_at ?? r.finished_at ?? Date.now() / 1000) * 1000,
|
||||
subagents: normalised
|
||||
}
|
||||
|
||||
const next = [snap, ...$spawnHistory.get()].slice(0, HISTORY_LIMIT)
|
||||
$spawnHistory.set(next)
|
||||
}
|
||||
|
||||
function normaliseSubagent(raw: unknown): SubagentProgress {
|
||||
const o = raw as Record<string, unknown>
|
||||
const s = (v: unknown) => (typeof v === 'string' ? v : undefined)
|
||||
const n = (v: unknown) => (typeof v === 'number' ? v : undefined)
|
||||
const arr = <T>(v: unknown): T[] | undefined => (Array.isArray(v) ? (v as T[]) : undefined)
|
||||
|
||||
return {
|
||||
apiCalls: n(o.apiCalls),
|
||||
costUsd: n(o.costUsd),
|
||||
depth: typeof o.depth === 'number' ? o.depth : 0,
|
||||
durationSeconds: n(o.durationSeconds),
|
||||
filesRead: arr<string>(o.filesRead),
|
||||
filesWritten: arr<string>(o.filesWritten),
|
||||
goal: s(o.goal) ?? 'subagent',
|
||||
id: s(o.id) ?? `sa-${Math.random().toString(36).slice(2, 8)}`,
|
||||
index: typeof o.index === 'number' ? o.index : 0,
|
||||
inputTokens: n(o.inputTokens),
|
||||
iteration: n(o.iteration),
|
||||
model: s(o.model),
|
||||
notes: (arr<string>(o.notes) ?? []).filter(x => typeof x === 'string'),
|
||||
outputTail: arr(o.outputTail) as SubagentProgress['outputTail'],
|
||||
outputTokens: n(o.outputTokens),
|
||||
parentId: s(o.parentId) ?? null,
|
||||
reasoningTokens: n(o.reasoningTokens),
|
||||
startedAt: n(o.startedAt),
|
||||
status: (s(o.status) as SubagentProgress['status']) ?? 'completed',
|
||||
summary: s(o.summary),
|
||||
taskCount: typeof o.taskCount === 'number' ? o.taskCount : 1,
|
||||
thinking: (arr<string>(o.thinking) ?? []).filter(x => typeof x === 'string'),
|
||||
toolCount: typeof o.toolCount === 'number' ? o.toolCount : 0,
|
||||
tools: (arr<string>(o.tools) ?? []).filter(x => typeof x === 'string'),
|
||||
toolsets: arr<string>(o.toolsets)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue