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
1036
ui-tui/src/components/agentsOverlay.tsx
Normal file
1036
ui-tui/src/components/agentsOverlay.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,14 @@
|
|||
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
|
||||
import { type ReactNode, type RefObject, useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, type RefObject, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
|
||||
import { $delegationState } from '../app/delegationStore.js'
|
||||
import { $turnState } from '../app/turnStore.js'
|
||||
import { FACES } from '../content/faces.js'
|
||||
import { VERBS } from '../content/verbs.js'
|
||||
import { fmtDuration } from '../domain/messages.js'
|
||||
import { stickyPromptFromViewport } from '../domain/viewport.js'
|
||||
import { buildSubagentTree, treeTotals } from '../lib/subagentTree.js'
|
||||
import { fmtK } from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { Msg, Usage } from '../types.js'
|
||||
|
|
@ -60,6 +64,58 @@ function ctxBar(pct: number | undefined, w = 10) {
|
|||
return '█'.repeat(filled) + '░'.repeat(w - filled)
|
||||
}
|
||||
|
||||
function SpawnHud({ t }: { t: Theme }) {
|
||||
// Tight HUD that only appears when the session is actually fanning out.
|
||||
// Colour escalates to warn/error as depth or concurrency approaches the cap.
|
||||
const delegation = useStore($delegationState)
|
||||
const turn = useStore($turnState)
|
||||
|
||||
const tree = useMemo(() => buildSubagentTree(turn.subagents), [turn.subagents])
|
||||
const totals = useMemo(() => treeTotals(tree), [tree])
|
||||
|
||||
if (!totals.descendantCount && !delegation.paused) {
|
||||
return null
|
||||
}
|
||||
|
||||
const maxDepth = delegation.maxSpawnDepth
|
||||
const maxConc = delegation.maxConcurrentChildren
|
||||
const depth = Math.max(0, totals.maxDepthFromHere)
|
||||
const active = totals.activeCount
|
||||
|
||||
// Concurrency here is "concurrent top-level spawns per parent at the
|
||||
// tightest branch" — approximated by the widest level in the tree.
|
||||
const depthRatio = maxDepth ? depth / maxDepth : 0
|
||||
const concRatio = maxConc ? active / maxConc : 0
|
||||
const ratio = Math.max(depthRatio, concRatio)
|
||||
|
||||
const color = delegation.paused || ratio >= 1 ? t.color.error : ratio >= 0.66 ? t.color.warn : t.color.dim
|
||||
|
||||
const pieces: string[] = []
|
||||
|
||||
if (delegation.paused) {
|
||||
pieces.push('⏸ paused')
|
||||
}
|
||||
|
||||
if (totals.descendantCount > 0) {
|
||||
const depthLabel = maxDepth ? `${depth}/${maxDepth}` : `${depth}`
|
||||
pieces.push(`d${depthLabel}`)
|
||||
|
||||
if (active > 0) {
|
||||
const concLabel = maxConc ? `${active}/${maxConc}` : `${active}`
|
||||
pieces.push(`⚡${concLabel}`)
|
||||
}
|
||||
}
|
||||
|
||||
const atCap = depthRatio >= 1 || concRatio >= 1
|
||||
|
||||
return (
|
||||
<Text color={color}>
|
||||
{atCap ? ' │ ⚠ ' : ' │ '}
|
||||
{pieces.join(' ')}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionDuration({ startedAt }: { startedAt: number }) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
|
|
@ -145,6 +201,7 @@ export function StatusRule({
|
|||
<SessionDuration startedAt={sessionStartedAt} />
|
||||
</Text>
|
||||
) : null}
|
||||
<SpawnHud t={t} />
|
||||
{voiceLabel ? <Text color={t.color.dim}> │ {voiceLabel}</Text> : null}
|
||||
{bgCount > 0 ? <Text color={t.color.dim}> │ {bgCount} bg</Text> : null}
|
||||
{showCost && typeof usage.cost_usd === 'number' ? (
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@ import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { memo } from 'react'
|
||||
|
||||
import { useGateway } from '../app/gatewayContext.js'
|
||||
import type { AppLayoutProgressProps, AppLayoutProps } from '../app/interfaces.js'
|
||||
import { $isBlocked } from '../app/overlayStore.js'
|
||||
import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js'
|
||||
import { $uiState } from '../app/uiStore.js'
|
||||
import { PLACEHOLDER } from '../content/placeholders.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { DetailsMode } from '../types.js'
|
||||
|
||||
import { AgentsOverlay } from './agentsOverlay.js'
|
||||
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
|
||||
import { FloatingOverlays, PromptZone } from './appOverlays.js'
|
||||
import { Banner, Panel, SessionPanel } from './branding.js'
|
||||
|
|
@ -256,6 +258,21 @@ const ComposerPane = memo(function ComposerPane({
|
|||
)
|
||||
})
|
||||
|
||||
const AgentsOverlayPane = memo(function AgentsOverlayPane() {
|
||||
const { gw } = useGateway()
|
||||
const ui = useStore($uiState)
|
||||
const overlay = useStore($overlayState)
|
||||
|
||||
return (
|
||||
<AgentsOverlay
|
||||
gw={gw}
|
||||
initialHistoryIndex={overlay.agentsInitialHistoryIndex}
|
||||
onClose={() => patchOverlayState({ agents: false, agentsInitialHistoryIndex: 0 })}
|
||||
t={ui.theme}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export const AppLayout = memo(function AppLayout({
|
||||
actions,
|
||||
composer,
|
||||
|
|
@ -264,22 +281,30 @@ export const AppLayout = memo(function AppLayout({
|
|||
status,
|
||||
transcript
|
||||
}: AppLayoutProps) {
|
||||
const overlay = useStore($overlayState)
|
||||
|
||||
return (
|
||||
<AlternateScreen mouseTracking={mouseTracking}>
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Box flexDirection="row" flexGrow={1}>
|
||||
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
|
||||
{overlay.agents ? (
|
||||
<AgentsOverlayPane />
|
||||
) : (
|
||||
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<PromptZone
|
||||
cols={composer.cols}
|
||||
onApprovalChoice={actions.answerApproval}
|
||||
onClarifyAnswer={actions.answerClarify}
|
||||
onSecretSubmit={actions.answerSecret}
|
||||
onSudoSubmit={actions.answerSudo}
|
||||
/>
|
||||
{!overlay.agents && (
|
||||
<PromptZone
|
||||
cols={composer.cols}
|
||||
onApprovalChoice={actions.answerApproval}
|
||||
onClarifyAnswer={actions.answerClarify}
|
||||
onSecretSubmit={actions.answerSecret}
|
||||
onSudoSubmit={actions.answerSudo}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ComposerPane actions={actions} composer={composer} status={status} />
|
||||
{!overlay.agents && <ComposerPane actions={actions} composer={composer} status={status} />}
|
||||
</Box>
|
||||
</AlternateScreen>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,19 @@
|
|||
import { Box, NoSelect, Text } from '@hermes/ink'
|
||||
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { memo, type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
|
||||
|
||||
import { THINKING_COT_MAX } from '../config/limits.js'
|
||||
import {
|
||||
buildSubagentTree,
|
||||
fmtCost,
|
||||
fmtTokens,
|
||||
formatSummary as formatSpawnSummary,
|
||||
hotnessBucket,
|
||||
peakHotness,
|
||||
sparkline,
|
||||
treeTotals,
|
||||
widthByDepth
|
||||
} from '../lib/subagentTree.js'
|
||||
import {
|
||||
compactPreview,
|
||||
estimateTokensRough,
|
||||
|
|
@ -14,7 +25,7 @@ import {
|
|||
toolTrailLabel
|
||||
} from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { ActiveTool, ActivityItem, DetailsMode, SubagentProgress, ThinkingMode } from '../types.js'
|
||||
import type { ActiveTool, ActivityItem, DetailsMode, SubagentNode, SubagentProgress, ThinkingMode } from '../types.js'
|
||||
|
||||
const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse']
|
||||
const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle']
|
||||
|
|
@ -106,6 +117,8 @@ function TreeNode({
|
|||
header,
|
||||
open,
|
||||
rails = [],
|
||||
stemColor,
|
||||
stemDim,
|
||||
t
|
||||
}: {
|
||||
branch: TreeBranch
|
||||
|
|
@ -113,11 +126,13 @@ function TreeNode({
|
|||
header: ReactNode
|
||||
open: boolean
|
||||
rails?: TreeRails
|
||||
stemColor?: string
|
||||
stemDim?: boolean
|
||||
t: Theme
|
||||
}) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<TreeRow branch={branch} rails={rails} t={t}>
|
||||
<TreeRow branch={branch} rails={rails} stemColor={stemColor} stemDim={stemDim} t={t}>
|
||||
{header}
|
||||
</TreeRow>
|
||||
{open ? children?.(nextTreeRails(rails, branch)) : null}
|
||||
|
|
@ -239,16 +254,31 @@ function Chevron({
|
|||
)
|
||||
}
|
||||
|
||||
function heatColor(node: SubagentNode, peak: number, theme: Theme): string | undefined {
|
||||
const palette = [theme.color.bronze, theme.color.amber, theme.color.gold, theme.color.warn, theme.color.error]
|
||||
const idx = hotnessBucket(node.aggregate.hotness, peak, palette.length)
|
||||
|
||||
// Below the median bucket we keep the default dim stem so cool branches
|
||||
// fade into the chrome — only "hot" branches draw the eye.
|
||||
if (idx < 2) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return palette[idx]
|
||||
}
|
||||
|
||||
function SubagentAccordion({
|
||||
branch,
|
||||
expanded,
|
||||
item,
|
||||
node,
|
||||
peak,
|
||||
rails = [],
|
||||
t
|
||||
}: {
|
||||
branch: TreeBranch
|
||||
expanded: boolean
|
||||
item: SubagentProgress
|
||||
node: SubagentNode
|
||||
peak: number
|
||||
rails?: TreeRails
|
||||
t: Theme
|
||||
}) {
|
||||
|
|
@ -257,6 +287,7 @@ function SubagentAccordion({
|
|||
const [openThinking, setOpenThinking] = useState(expanded)
|
||||
const [openTools, setOpenTools] = useState(expanded)
|
||||
const [openNotes, setOpenNotes] = useState(expanded)
|
||||
const [openKids, setOpenKids] = useState(expanded)
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) {
|
||||
|
|
@ -268,6 +299,7 @@ function SubagentAccordion({
|
|||
setOpenThinking(true)
|
||||
setOpenTools(true)
|
||||
setOpenNotes(true)
|
||||
setOpenKids(true)
|
||||
}, [expanded])
|
||||
|
||||
const expandAll = () => {
|
||||
|
|
@ -276,8 +308,13 @@ function SubagentAccordion({
|
|||
setOpenThinking(true)
|
||||
setOpenTools(true)
|
||||
setOpenNotes(true)
|
||||
setOpenKids(true)
|
||||
}
|
||||
|
||||
const item = node.item
|
||||
const children = node.children
|
||||
const aggregate = node.aggregate
|
||||
|
||||
const statusTone: 'dim' | 'error' | 'warn' =
|
||||
item.status === 'failed' ? 'error' : item.status === 'interrupted' ? 'warn' : 'dim'
|
||||
|
||||
|
|
@ -286,10 +323,60 @@ function SubagentAccordion({
|
|||
const title = `${prefix}${open ? goalLabel : compactPreview(goalLabel, 60)}`
|
||||
const summary = compactPreview((item.summary || '').replace(/\s+/g, ' ').trim(), 72)
|
||||
|
||||
const suffix =
|
||||
item.status === 'running'
|
||||
? 'running'
|
||||
: `${item.status}${item.durationSeconds ? ` · ${fmtElapsed(item.durationSeconds * 1000)}` : ''}`
|
||||
// Suffix packs branch rollup: status · elapsed · per-branch tool/agent/token/cost.
|
||||
// Emphasises the numbers the user can't easily eyeball from a flat list.
|
||||
const statusLabel = item.status === 'queued' ? 'queued' : item.status === 'running' ? 'running' : String(item.status)
|
||||
|
||||
const rollupBits: string[] = [statusLabel]
|
||||
|
||||
if (item.durationSeconds) {
|
||||
rollupBits.push(fmtElapsed(item.durationSeconds * 1000))
|
||||
}
|
||||
|
||||
const localTools = item.toolCount ?? 0
|
||||
const subtreeTools = aggregate.totalTools - localTools
|
||||
|
||||
if (localTools > 0) {
|
||||
rollupBits.push(`${localTools} tool${localTools === 1 ? '' : 's'}`)
|
||||
}
|
||||
|
||||
const localTokens = (item.inputTokens ?? 0) + (item.outputTokens ?? 0)
|
||||
|
||||
if (localTokens > 0) {
|
||||
rollupBits.push(`${fmtTokens(localTokens)} tok`)
|
||||
}
|
||||
|
||||
const localCost = item.costUsd ?? 0
|
||||
|
||||
if (localCost > 0) {
|
||||
rollupBits.push(fmtCost(localCost))
|
||||
}
|
||||
|
||||
const filesLocal = (item.filesWritten?.length ?? 0) + (item.filesRead?.length ?? 0)
|
||||
|
||||
if (filesLocal > 0) {
|
||||
rollupBits.push(`⎘${filesLocal}`)
|
||||
}
|
||||
|
||||
if (children.length > 0) {
|
||||
rollupBits.push(`${aggregate.descendantCount}↓`)
|
||||
|
||||
if (subtreeTools > 0) {
|
||||
rollupBits.push(`+${subtreeTools}t sub`)
|
||||
}
|
||||
|
||||
const subCost = aggregate.costUsd - localCost
|
||||
|
||||
if (subCost >= 0.01) {
|
||||
rollupBits.push(`+${fmtCost(subCost)} sub`)
|
||||
}
|
||||
|
||||
if (aggregate.activeCount > 0 && item.status !== 'running') {
|
||||
rollupBits.push(`⚡${aggregate.activeCount}`)
|
||||
}
|
||||
}
|
||||
|
||||
const suffix = rollupBits.join(' · ')
|
||||
|
||||
const thinkingText = item.thinking.join('\n')
|
||||
const hasThinking = Boolean(thinkingText)
|
||||
|
|
@ -418,6 +505,50 @@ function SubagentAccordion({
|
|||
})
|
||||
}
|
||||
|
||||
if (children.length > 0) {
|
||||
// Nested grandchildren — rendered recursively via SubagentAccordion,
|
||||
// sharing the same keybindings / expand semantics as top-level nodes.
|
||||
sections.push({
|
||||
header: (
|
||||
<Chevron
|
||||
count={children.length}
|
||||
onClick={shift => {
|
||||
if (shift) {
|
||||
expandAll()
|
||||
} else {
|
||||
setOpenKids(v => !v)
|
||||
}
|
||||
}}
|
||||
open={showChildren || openKids}
|
||||
suffix={`d${item.depth + 1} · ${aggregate.descendantCount} total`}
|
||||
t={t}
|
||||
title="Spawned"
|
||||
/>
|
||||
),
|
||||
key: 'subagents',
|
||||
open: showChildren || openKids,
|
||||
render: childRails => (
|
||||
<Box flexDirection="column">
|
||||
{children.map((child, i) => (
|
||||
<SubagentAccordion
|
||||
branch={i === children.length - 1 ? 'last' : 'mid'}
|
||||
expanded={expanded || deep}
|
||||
key={child.item.id}
|
||||
node={child}
|
||||
peak={peak}
|
||||
rails={childRails}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Heatmap: amber→error gradient on the stem when this branch is "hot"
|
||||
// (high tools/sec) relative to the whole tree's peak.
|
||||
const stem = heatColor(node, peak, t)
|
||||
|
||||
return (
|
||||
<TreeNode
|
||||
branch={branch}
|
||||
|
|
@ -447,6 +578,8 @@ function SubagentAccordion({
|
|||
}
|
||||
open={open}
|
||||
rails={rails}
|
||||
stemColor={stem}
|
||||
stemDim={stem == null}
|
||||
t={t}
|
||||
>
|
||||
{childRails => (
|
||||
|
|
@ -598,6 +731,16 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
|
||||
const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning])
|
||||
|
||||
// Spawn-tree derivations must live above any early return so React's
|
||||
// rules-of-hooks sees a stable call order. Cheap O(N) builds memoised
|
||||
// by subagent-list identity.
|
||||
const spawnTree = useMemo(() => buildSubagentTree(subagents), [subagents])
|
||||
const spawnPeak = useMemo(() => peakHotness(spawnTree), [spawnTree])
|
||||
const spawnTotals = useMemo(() => treeTotals(spawnTree), [spawnTree])
|
||||
const spawnWidths = useMemo(() => widthByDepth(spawnTree), [spawnTree])
|
||||
const spawnSpark = useMemo(() => sparkline(spawnWidths), [spawnWidths])
|
||||
const spawnSummaryLabel = useMemo(() => formatSpawnSummary(spawnTotals), [spawnTotals])
|
||||
|
||||
if (
|
||||
!busy &&
|
||||
!trail.length &&
|
||||
|
|
@ -753,12 +896,13 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
|
||||
const renderSubagentList = (rails: boolean[]) => (
|
||||
<Box flexDirection="column">
|
||||
{subagents.map((item, index) => (
|
||||
{spawnTree.map((node, index) => (
|
||||
<SubagentAccordion
|
||||
branch={index === subagents.length - 1 ? 'last' : 'mid'}
|
||||
branch={index === spawnTree.length - 1 ? 'last' : 'mid'}
|
||||
expanded={detailsMode === 'expanded' || deepSubagents}
|
||||
item={item}
|
||||
key={item.id}
|
||||
key={node.item.id}
|
||||
node={node}
|
||||
peak={spawnPeak}
|
||||
rails={rails}
|
||||
t={t}
|
||||
/>
|
||||
|
|
@ -881,10 +1025,14 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
}
|
||||
|
||||
if (hasSubagents && !inlineDelegateKey) {
|
||||
// Spark + summary give a one-line read on the branch shape before
|
||||
// opening the subtree. `/agents` opens the full-screen audit overlay.
|
||||
const suffix = spawnSpark ? `${spawnSummaryLabel} ${spawnSpark} (/agents)` : `${spawnSummaryLabel} (/agents)`
|
||||
|
||||
sections.push({
|
||||
header: (
|
||||
<Chevron
|
||||
count={subagents.length}
|
||||
count={spawnTotals.descendantCount}
|
||||
onClick={shift => {
|
||||
if (shift) {
|
||||
expandAll()
|
||||
|
|
@ -895,8 +1043,9 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
}
|
||||
}}
|
||||
open={detailsMode === 'expanded' || openSubagents}
|
||||
suffix={suffix}
|
||||
t={t}
|
||||
title="Subagents"
|
||||
title="Spawn tree"
|
||||
/>
|
||||
),
|
||||
key: 'subagents',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue