mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Pull duplicated rules into ui-tui/src/lib/subagentTree so the live overlay, disk snapshot label, and diff pane all speak one dialect: - export fmtDuration(seconds) — was a private helper in subagentTree; agentsOverlay's local secLabel/fmtDur/fmtElapsedLabel now wrap the same core (with UI-only empty-string policy). - export topLevelSubagents(items) — matches buildSubagentTree's orphan semantics (no parent OR parent not in snapshot). Replaces three hand- rolled copies across createGatewayEventHandler (disk label), agentsOverlay DiffPane, and prior inline filters. Also collapse agentsOverlay boilerplate: - replace IIFE title + inner `delta` helper with straight expressions; - introduce module-level diffMetricLine for replay-diff rows; - tighten OverlayScrollbar (single thumbColor expression, vBar/thumbBody). Adds unit coverage for the new exports (fmtDuration + topLevelSubagents). No behaviour change; 221 tests pass.
355 lines
9.6 KiB
TypeScript
355 lines
9.6 KiB
TypeScript
import type { SubagentAggregate, SubagentNode, SubagentProgress } from '../types.js'
|
|
|
|
const ROOT_KEY = '__root__'
|
|
|
|
/**
|
|
* Reconstruct the subagent spawn tree from a flat event-ordered list.
|
|
*
|
|
* Grouping is by `parentId`; a missing `parentId` (or one pointing at an
|
|
* unknown subagent) is treated as a top-level spawn of the current turn.
|
|
* Children within a parent are sorted by `depth` then `index` — same key
|
|
* used in `turnController.upsertSubagent`, so render order matches spawn
|
|
* order regardless of network reordering of gateway events.
|
|
*
|
|
* Older gateways omit `parentId`; every subagent is then a top-level node
|
|
* and the tree renders flat — matching pre-observability behaviour.
|
|
*/
|
|
export function buildSubagentTree(items: readonly SubagentProgress[]): SubagentNode[] {
|
|
if (!items.length) {
|
|
return []
|
|
}
|
|
|
|
const byParent = new Map<string, SubagentProgress[]>()
|
|
const known = new Set<string>()
|
|
|
|
for (const item of items) {
|
|
known.add(item.id)
|
|
}
|
|
|
|
for (const item of items) {
|
|
const parentKey = item.parentId && known.has(item.parentId) ? item.parentId : ROOT_KEY
|
|
const bucket = byParent.get(parentKey) ?? []
|
|
bucket.push(item)
|
|
byParent.set(parentKey, bucket)
|
|
}
|
|
|
|
for (const bucket of byParent.values()) {
|
|
bucket.sort((a, b) => a.depth - b.depth || a.index - b.index)
|
|
}
|
|
|
|
const build = (item: SubagentProgress): SubagentNode => {
|
|
const kids = byParent.get(item.id) ?? []
|
|
const children = kids.map(build)
|
|
|
|
return { aggregate: aggregate(item, children), children, item }
|
|
}
|
|
|
|
return (byParent.get(ROOT_KEY) ?? []).map(build)
|
|
}
|
|
|
|
/**
|
|
* Roll up counts for a node's whole subtree. Kept pure so the live view
|
|
* and the post-hoc replay can share the same renderer unchanged.
|
|
*
|
|
* `hotness` = tools per second across the subtree — a crude proxy for
|
|
* "how much work is happening in this branch". Used to colour tree rails
|
|
* in the overlay / inline view so the eye spots the expensive branch.
|
|
*/
|
|
export function aggregate(item: SubagentProgress, children: readonly SubagentNode[]): SubagentAggregate {
|
|
let totalTools = item.toolCount ?? 0
|
|
let totalDuration = item.durationSeconds ?? 0
|
|
let descendantCount = 0
|
|
let activeCount = isRunning(item) ? 1 : 0
|
|
let maxDepthFromHere = 0
|
|
let inputTokens = item.inputTokens ?? 0
|
|
let outputTokens = item.outputTokens ?? 0
|
|
let costUsd = item.costUsd ?? 0
|
|
let filesTouched = (item.filesRead?.length ?? 0) + (item.filesWritten?.length ?? 0)
|
|
|
|
for (const child of children) {
|
|
totalTools += child.aggregate.totalTools
|
|
totalDuration += child.aggregate.totalDuration
|
|
descendantCount += child.aggregate.descendantCount + 1
|
|
activeCount += child.aggregate.activeCount
|
|
maxDepthFromHere = Math.max(maxDepthFromHere, child.aggregate.maxDepthFromHere + 1)
|
|
inputTokens += child.aggregate.inputTokens
|
|
outputTokens += child.aggregate.outputTokens
|
|
costUsd += child.aggregate.costUsd
|
|
filesTouched += child.aggregate.filesTouched
|
|
}
|
|
|
|
const hotness = totalDuration > 0 ? totalTools / totalDuration : 0
|
|
|
|
return {
|
|
activeCount,
|
|
costUsd,
|
|
descendantCount,
|
|
filesTouched,
|
|
hotness,
|
|
inputTokens,
|
|
maxDepthFromHere,
|
|
outputTokens,
|
|
totalDuration,
|
|
totalTools
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Count of subagents at each depth level, indexed by depth (0 = top level).
|
|
* Drives the inline sparkline (`▁▃▇▅`) and the status-bar HUD.
|
|
*/
|
|
export function widthByDepth(tree: readonly SubagentNode[]): number[] {
|
|
const widths: number[] = []
|
|
|
|
const walk = (nodes: readonly SubagentNode[], depth: number) => {
|
|
if (!nodes.length) {
|
|
return
|
|
}
|
|
|
|
widths[depth] = (widths[depth] ?? 0) + nodes.length
|
|
|
|
for (const node of nodes) {
|
|
walk(node.children, depth + 1)
|
|
}
|
|
}
|
|
|
|
walk(tree, 0)
|
|
|
|
return widths
|
|
}
|
|
|
|
/**
|
|
* Flat totals across the full tree — feeds the summary chip header.
|
|
*/
|
|
export function treeTotals(tree: readonly SubagentNode[]): SubagentAggregate {
|
|
let totalTools = 0
|
|
let totalDuration = 0
|
|
let descendantCount = 0
|
|
let activeCount = 0
|
|
let maxDepthFromHere = 0
|
|
let inputTokens = 0
|
|
let outputTokens = 0
|
|
let costUsd = 0
|
|
let filesTouched = 0
|
|
|
|
for (const node of tree) {
|
|
totalTools += node.aggregate.totalTools
|
|
totalDuration += node.aggregate.totalDuration
|
|
descendantCount += node.aggregate.descendantCount + 1
|
|
activeCount += node.aggregate.activeCount
|
|
maxDepthFromHere = Math.max(maxDepthFromHere, node.aggregate.maxDepthFromHere + 1)
|
|
inputTokens += node.aggregate.inputTokens
|
|
outputTokens += node.aggregate.outputTokens
|
|
costUsd += node.aggregate.costUsd
|
|
filesTouched += node.aggregate.filesTouched
|
|
}
|
|
|
|
const hotness = totalDuration > 0 ? totalTools / totalDuration : 0
|
|
|
|
return {
|
|
activeCount,
|
|
costUsd,
|
|
descendantCount,
|
|
filesTouched,
|
|
hotness,
|
|
inputTokens,
|
|
maxDepthFromHere,
|
|
outputTokens,
|
|
totalDuration,
|
|
totalTools
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Flatten the tree into visit order — useful for keyboard navigation and
|
|
* for "kill subtree" walks that fire one RPC per descendant.
|
|
*/
|
|
export function flattenTree(tree: readonly SubagentNode[]): SubagentNode[] {
|
|
const out: SubagentNode[] = []
|
|
|
|
const walk = (nodes: readonly SubagentNode[]) => {
|
|
for (const node of nodes) {
|
|
out.push(node)
|
|
walk(node.children)
|
|
}
|
|
}
|
|
|
|
walk(tree)
|
|
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Collect every descendant's id for a given node (excluding the node itself).
|
|
*/
|
|
export function descendantIds(node: SubagentNode): string[] {
|
|
const ids: string[] = []
|
|
|
|
const walk = (children: readonly SubagentNode[]) => {
|
|
for (const child of children) {
|
|
ids.push(child.item.id)
|
|
walk(child.children)
|
|
}
|
|
}
|
|
|
|
walk(node.children)
|
|
|
|
return ids
|
|
}
|
|
|
|
export function isRunning(item: Pick<SubagentProgress, 'status'>): boolean {
|
|
return item.status === 'running' || item.status === 'queued'
|
|
}
|
|
|
|
const SPARK_RAMP = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const
|
|
|
|
/**
|
|
* 8-step unicode bar sparkline from a positive-integer array. Zeroes render
|
|
* as spaces so a sparse tree doesn't read as equal activity at every depth.
|
|
*/
|
|
export function sparkline(values: readonly number[]): string {
|
|
if (!values.length) {
|
|
return ''
|
|
}
|
|
|
|
const max = Math.max(...values)
|
|
|
|
if (max <= 0) {
|
|
return ' '.repeat(values.length)
|
|
}
|
|
|
|
return values
|
|
.map(v => {
|
|
if (v <= 0) {
|
|
return ' '
|
|
}
|
|
|
|
const idx = Math.min(SPARK_RAMP.length - 1, Math.max(0, Math.ceil((v / max) * (SPARK_RAMP.length - 1))))
|
|
|
|
return SPARK_RAMP[idx]
|
|
})
|
|
.join('')
|
|
}
|
|
|
|
/**
|
|
* Format totals into a compact one-line summary: `d2 · 7 agents · 124 tools · 2m 14s`
|
|
*/
|
|
export function formatSummary(totals: SubagentAggregate): string {
|
|
const pieces = [`d${Math.max(0, totals.maxDepthFromHere)}`]
|
|
pieces.push(`${totals.descendantCount} agent${totals.descendantCount === 1 ? '' : 's'}`)
|
|
|
|
if (totals.totalTools > 0) {
|
|
pieces.push(`${totals.totalTools} tool${totals.totalTools === 1 ? '' : 's'}`)
|
|
}
|
|
|
|
if (totals.totalDuration > 0) {
|
|
pieces.push(fmtDuration(totals.totalDuration))
|
|
}
|
|
|
|
const tokens = totals.inputTokens + totals.outputTokens
|
|
|
|
if (tokens > 0) {
|
|
pieces.push(`${fmtTokens(tokens)} tok`)
|
|
}
|
|
|
|
if (totals.costUsd > 0) {
|
|
pieces.push(fmtCost(totals.costUsd))
|
|
}
|
|
|
|
if (totals.activeCount > 0) {
|
|
pieces.push(`⚡${totals.activeCount}`)
|
|
}
|
|
|
|
return pieces.join(' · ')
|
|
}
|
|
|
|
/** Compact dollar amount: `$0.02`, `$1.34`, `$12.4` — never > 5 chars beyond the `$`. */
|
|
export function fmtCost(usd: number): string {
|
|
if (!Number.isFinite(usd) || usd <= 0) {
|
|
return ''
|
|
}
|
|
|
|
if (usd < 0.01) {
|
|
return '<$0.01'
|
|
}
|
|
|
|
if (usd < 10) {
|
|
return `$${usd.toFixed(2)}`
|
|
}
|
|
|
|
return `$${usd.toFixed(1)}`
|
|
}
|
|
|
|
/** Compact token count: `12k`, `1.2k`, `542`. */
|
|
export function fmtTokens(n: number): string {
|
|
if (!Number.isFinite(n) || n <= 0) {
|
|
return '0'
|
|
}
|
|
|
|
if (n < 1000) {
|
|
return String(Math.round(n))
|
|
}
|
|
|
|
if (n < 10_000) {
|
|
return `${(n / 1000).toFixed(1)}k`
|
|
}
|
|
|
|
return `${Math.round(n / 1000)}k`
|
|
}
|
|
|
|
/**
|
|
* `Ns` / `Nm` / `Nm Ss` formatter for seconds. Shared with the agents
|
|
* overlay so the timeline + list + summary all speak the same dialect.
|
|
*/
|
|
export function fmtDuration(seconds: number): string {
|
|
if (seconds < 60) {
|
|
return `${Math.max(0, Math.round(seconds))}s`
|
|
}
|
|
|
|
const m = Math.floor(seconds / 60)
|
|
const s = Math.round(seconds - m * 60)
|
|
|
|
return s === 0 ? `${m}m` : `${m}m ${s}s`
|
|
}
|
|
|
|
/**
|
|
* A subagent is top-level if it has no `parentId`, or its parent isn't in
|
|
* the same snapshot (orphaned by a pruned mid-flight root). Same rule
|
|
* `buildSubagentTree` uses — keep call sites consistent across the live
|
|
* view, disk label, and diff pane.
|
|
*/
|
|
export function topLevelSubagents(items: readonly SubagentProgress[]): SubagentProgress[] {
|
|
const ids = new Set(items.map(s => s.id))
|
|
|
|
return items.filter(s => !s.parentId || !ids.has(s.parentId))
|
|
}
|
|
|
|
/**
|
|
* Normalize a node's hotness into a palette index 0..N-1 where N = buckets.
|
|
* Higher hotness = "hotter" colour. Normalized against the tree's peak hotness
|
|
* so a uniformly slow tree still shows gradient across its busiest branches.
|
|
*/
|
|
export function hotnessBucket(hotness: number, peakHotness: number, buckets: number): number {
|
|
if (!Number.isFinite(hotness) || hotness <= 0 || peakHotness <= 0 || buckets <= 1) {
|
|
return 0
|
|
}
|
|
|
|
const ratio = Math.min(1, hotness / peakHotness)
|
|
|
|
return Math.min(buckets - 1, Math.max(0, Math.round(ratio * (buckets - 1))))
|
|
}
|
|
|
|
export function peakHotness(tree: readonly SubagentNode[]): number {
|
|
let peak = 0
|
|
|
|
const walk = (nodes: readonly SubagentNode[]) => {
|
|
for (const node of nodes) {
|
|
peak = Math.max(peak, node.aggregate.hotness)
|
|
walk(node.children)
|
|
}
|
|
}
|
|
|
|
walk(tree)
|
|
|
|
return peak
|
|
}
|