Files
webui-develop/static/activity-tree.ts

529 lines
18 KiB
TypeScript

// ─── Agent Activity Tree (Mission Control) ──────────────────────────
// Full-depth parallel agent activity tracking for the 3-Tier system.
// Provides: initActivityTree, addNode, addToolCall, updateToolCall,
// finalizeNode, getStats, createMockActivityTree, formatElapsed
// Agent metadata — maps agent IDs to display info
const AGENT_META: Record<string, { emoji: string; name: string; tier: 1|2|3 }> = {
rose: { emoji: '🌹', name: 'Rose', tier: 1 },
lotus: { emoji: '🪷', name: 'Lotus', tier: 2 },
'forget-me-not':{ emoji: '🌼', name: 'Forget-me-not', tier: 2 },
sunflower: { emoji: '🌻', name: 'Sunflower', tier: 2 },
iris: { emoji: '⚜️', name: 'Iris', tier: 2 },
ivy: { emoji: '🌿', name: 'Ivy', tier: 2 },
dandelion: { emoji: '🛡', name: 'Dandelion', tier: 2 },
root: { emoji: '🌳', name: 'Root', tier: 2 },
};
// Tier-3 sub-agent detection — tool names that indicate sub-agent work
const TIER3_TOOL_NAMES = new Set([
'search_files', 'read_file', 'write_file', 'terminal', 'browser_navigate',
'browser_snapshot', 'browser_click', 'browser_type', 'browser_press',
'web_search', 'delegate_task', 'execute_code', 'patch',
]);
let _nodeCounter = 0;
function _nextNodeId(agentId: string): string {
return `${agentId}-${++_nodeCounter}`;
}
// ─── Core Functions ────────────────────────────────────────────────
function initActivityTree(): ActivityTree {
const tree: ActivityTree = {
version: 1,
rootId: 'rose',
nodes: {
rose: {
id: 'rose',
parentId: null,
agentId: 'rose',
agentEmoji: '🌹',
agentName: 'Rose',
tier: 1,
status: 'running',
task: 'Orchestrating',
toolCalls: [],
startedAt: Date.now(),
endedAt: null,
duration: null,
children: [],
collapsed: false,
metadata: {},
}
},
stats: _emptyStats(),
};
S.activityTree = tree;
S.mcFilter = {};
S.mcSort = 'runtime';
return tree;
}
function _emptyStats(): MCStats {
return {
totalAgents: 0,
runningAgents: 0,
pendingAgents: 0,
doneAgents: 0,
errorAgents: 0,
totalTools: 0,
doneTools: 0,
runningTools: 0,
avgResponseTime: 0,
totalElapsed: 0,
};
}
// ─── Node Operations ───────────────────────────────────────────────
function atAddNode(opts: {
parentId?: string;
agentId: string;
task: string;
status?: ActivityNode['status'];
tier?: 1|2|3;
}): ActivityNode | null {
const tree = S.activityTree;
if (!tree) return null;
const parentId = opts.parentId || tree.rootId;
const parent = tree.nodes[parentId];
if (!parent) return null;
const meta = AGENT_META[opts.agentId];
const tier = opts.tier || (meta ? meta.tier : 3);
const id = _nextNodeId(opts.agentId);
const node: ActivityNode = {
id,
parentId,
agentId: opts.agentId,
agentEmoji: meta?.emoji || '⚙️',
agentName: meta?.name || opts.agentId,
tier,
status: opts.status || 'pending',
task: opts.task,
toolCalls: [],
startedAt: opts.status === 'running' ? Date.now() : null,
endedAt: null,
duration: null,
children: [],
collapsed: false,
metadata: {},
};
tree.nodes[id] = node;
parent.children.push(id);
_recalcStats(tree);
return node;
}
function atUpdateNode(nodeId: string, updates: Partial<ActivityNode>): void {
const tree = S.activityTree;
if (!tree) return;
const node = tree.nodes[nodeId];
if (!node) return;
Object.assign(node, updates);
_recalcStats(tree);
}
function atGetNode(nodeId: string): ActivityNode | null {
return S.activityTree?.nodes[nodeId] || null;
}
function atGetChildren(nodeId: string): ActivityNode[] {
const tree = S.activityTree;
if (!tree) return [];
const node = tree.nodes[nodeId];
if (!node) return [];
return node.children.map(id => tree.nodes[id]).filter(Boolean);
}
function atGetRunningNodes(): ActivityNode[] {
const tree = S.activityTree;
if (!tree) return [];
return Object.values(tree.nodes).filter(
n => n.status === 'running' || n.status === 'thinking'
);
}
// ─── Tool Call Operations ──────────────────────────────────────────
function atAddToolCall(nodeId: string, tc: {
name: string;
args?: Record<string, any>;
status?: ActivityToolCall['status'];
}): ActivityToolCall | null {
const tree = S.activityTree;
if (!tree) return null;
const node = tree.nodes[nodeId];
if (!node) return null;
// If node was pending, it's now running
if (node.status === 'pending') {
node.status = 'running';
node.startedAt = node.startedAt || Date.now();
}
const toolCall: ActivityToolCall = {
id: `tc-${++_nodeCounter}`,
name: tc.name,
status: tc.status || 'running',
args: tc.args || {},
startedAt: Date.now(),
endedAt: null,
};
node.toolCalls.push(toolCall);
_recalcStats(tree);
return toolCall;
}
function atUpdateToolCall(nodeId: string, toolId: string, updates: Partial<ActivityToolCall>): void {
const tree = S.activityTree;
if (!tree) return;
const node = tree.nodes[nodeId];
if (!node) return;
const tc = node.toolCalls.find(t => t.id === toolId);
if (!tc) return;
Object.assign(tc, updates);
if (updates.status === 'done' || updates.status === 'error') {
tc.endedAt = tc.endedAt || Date.now();
tc.duration = tc.startedAt ? (tc.endedAt - tc.startedAt) / 1000 : null;
}
_recalcStats(tree);
}
// ─── Finalization ──────────────────────────────────────────────────
function atFinalizeNode(nodeId: string, status: ActivityNode['status']): void {
const tree = S.activityTree;
if (!tree) return;
const node = tree.nodes[nodeId];
if (!node) return;
node.status = status;
node.endedAt = Date.now();
node.duration = node.startedAt ? (node.endedAt - node.startedAt) / 1000 : null;
// Finalize any still-running tool calls
for (const tc of node.toolCalls) {
if (tc.status === 'running' || tc.status === 'pending') {
tc.status = status === 'error' ? 'error' : 'done';
tc.endedAt = node.endedAt;
tc.duration = tc.startedAt ? (tc.endedAt - tc.startedAt) / 1000 : null;
}
}
// Recursively finalize children that are still active
for (const childId of node.children) {
const child = tree.nodes[childId];
if (child && (child.status === 'running' || child.status === 'thinking' || child.status === 'pending')) {
atFinalizeNode(childId, status);
}
}
_recalcStats(tree);
}
// ─── Stats ─────────────────────────────────────────────────────────
function _recalcStats(tree: ActivityTree): void {
const nodes = Object.values(tree.nodes).filter(n => n.id !== tree.rootId);
const tools = nodes.flatMap(n => n.toolCalls);
const doneDurations = nodes
.filter(n => n.duration !== null)
.map(n => n.duration!);
const runningNodes = nodes.filter(n => n.status === 'running' || n.status === 'thinking');
const maxElapsed = runningNodes.reduce((max, n) => {
const elapsed = n.startedAt ? (Date.now() - n.startedAt) / 1000 : 0;
return Math.max(max, elapsed);
}, 0);
tree.stats = {
totalAgents: nodes.length,
runningAgents: runningNodes.length,
pendingAgents: nodes.filter(n => n.status === 'pending').length,
doneAgents: nodes.filter(n => n.status === 'done').length,
errorAgents: nodes.filter(n => n.status === 'error').length,
totalTools: tools.length,
doneTools: tools.filter(t => t.status === 'done').length,
runningTools: tools.filter(t => t.status === 'running').length,
avgResponseTime: doneDurations.length
? doneDurations.reduce((a, b) => a + b, 0) / doneDurations.length
: 0,
totalElapsed: maxElapsed,
};
}
function atGetStats(): MCStats {
return S.activityTree?.stats || _emptyStats();
}
// ─── Reset ─────────────────────────────────────────────────────────
function atReset(): void {
_nodeCounter = 0;
initActivityTree();
}
// ─── Formatting ────────────────────────────────────────────────────
function formatElapsed(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rs = s % 60;
if (m < 60) return `${m}m ${rs}s`;
const h = Math.floor(m / 60);
const rm = m % 60;
return `${h}h ${rm}m`;
}
function formatDuration(seconds: number | null): string {
if (seconds === null) return '—';
if (seconds < 0.01) return '<0.01s';
if (seconds < 1) return `${seconds.toFixed(2)}s`;
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const m = Math.floor(seconds / 60);
const rs = Math.round(seconds % 60);
return `${m}m ${rs}s`;
}
// ─── Mock Data ─────────────────────────────────────────────────────
function createMockActivityTree(): ActivityTree {
initActivityTree();
const tree = S.activityTree!;
const now = Date.now();
// Lotus — running with 2 tool calls
const lotus = atAddNode({ agentId: 'lotus', task: 'Analysiere Gesundheitsdaten', status: 'running' })!;
lotus.startedAt = now - 12000;
atAddToolCall(lotus.id, { name: 'search_files', args: { pattern: 'health*', path: '/data' } });
atUpdateToolCall(lotus.id, lotus.toolCalls[0].id, { status: 'done', result: '12 matches', duration: 0.3 });
lotus.toolCalls[0].startedAt = now - 11000;
lotus.toolCalls[0].endedAt = now - 10997;
atAddToolCall(lotus.id, { name: 'read_file', args: { path: '/data/health-log.md' } });
// Sunflower — running with 1 done, 1 running tool
const sunflower = atAddNode({ agentId: 'sunflower', task: 'Portfolio-Analyse Q1 2026', status: 'running' })!;
sunflower.startedAt = now - 8000;
atAddToolCall(sunflower.id, { name: 'browser_navigate', args: { url: 'https://finance.example.com' } });
atUpdateToolCall(sunflower.id, sunflower.toolCalls[0].id, { status: 'done', result: 'Page loaded', duration: 2.1 });
sunflower.toolCalls[0].startedAt = now - 7500;
sunflower.toolCalls[0].endedAt = now - 5400;
atAddToolCall(sunflower.id, { name: 'terminal', args: { command: 'python3 analyse.py' } });
// Dandelion — pending
const dandelion = atAddNode({ agentId: 'dandelion', task: 'Triaging unread messages', status: 'pending' })!;
// Add a tier-3 sub-agent under Lotus
const researcher = atAddNode({ parentId: lotus.id, agentId: 'researcher', task: 'Looking up nutrition data', status: 'running', tier: 3 })!;
researcher.agentEmoji = '🔍';
researcher.agentName = 'Researcher';
researcher.startedAt = now - 3000;
atAddToolCall(researcher.id, { name: 'web_search', args: { query: 'nutrition database API' } });
_recalcStats(tree);
return tree;
}
// ─── Make core functions global (IIFE-safe) ────────────────────────
window.initActivityTree = initActivityTree;
window.createMockActivityTree = createMockActivityTree;
window.formatElapsed = formatElapsed;
window.formatDuration = formatDuration;
window.atAddNode = atAddNode;
window.atUpdateNode = atUpdateNode;
window.atGetNode = atGetNode;
window.atAddToolCall = atAddToolCall;
window.atUpdateToolCall = atUpdateToolCall;
window.atFinalizeNode = atFinalizeNode;
window.atGetStats = atGetStats;
window.atReset = atReset;
window._atTrackTool = _atTrackTool;
window._atTrackToolComplete = _atTrackToolComplete;
window._atTrackSubagent = _atTrackSubagent;
window._atTrackDone = _atTrackDone;
// ─── Export for console testing ─────────────────────────────────────
(window as any)._at = {
init: initActivityTree,
addNode: atAddNode,
updateNode: atUpdateNode,
getNode: atGetNode,
addToolCall: atAddToolCall,
updateToolCall: atUpdateToolCall,
finalize: atFinalizeNode,
stats: atGetStats,
reset: atReset,
mock: createMockActivityTree,
formatElapsed,
formatDuration,
AGENT_META,
trackSubagent: _atTrackSubagent,
};
// ─── SSE Event Bridge (called from messages.ts) ────────────────────
// Tracks which agent is currently active based on tool calls.
// When delegate_task is detected, creates a new agent node.
// Otherwise, attaches tool calls to the current active agent node.
let _activeAgentNodeId: string | null = null; // Current agent node receiving tool calls
let _lastToolId: string | null = null; // Last tool call ID (for complete matching)
function _atTrackTool(d: any): void {
if (!S.activityTree) initActivityTree();
const tree = S.activityTree!;
// Detect delegation — when tool name is 'delegate_task'
if (d.name === 'delegate_task') {
// Prefer explicit agent field from SSE payload (set by streaming.py
// when it detects delegate_task), then fall back to args lookup.
const agentId = d.agent || d.args?.agentId || d.args?.agent || d.args?.name || 'unknown';
const task = d.args?.goal || d.args?.prompt || d.args?.task || d.preview || 'Delegated task';
const node = atAddNode({ agentId, task, status: 'running' });
if (node) {
_activeAgentNodeId = node.id;
}
return;
}
// Determine which node to attach the tool call to
let targetNodeId = _activeAgentNodeId || tree.rootId;
// Check if this tool is from a specific agent (from inflight metadata)
const inflight = (window as any).INFLIGHT?.[S.session?.session_id];
if (inflight?.thisTurnAgent) {
const agentKey = inflight.thisTurnAgent;
// Find or create node for this agent
const existingNode = Object.values(tree.nodes).find(
(n: ActivityNode) => n.agentId === agentKey && (n.status === 'running' || n.status === 'thinking')
);
if (existingNode) {
targetNodeId = existingNode.id;
_activeAgentNodeId = existingNode.id;
} else if (AGENT_META[agentKey]) {
// New agent appeared — create node
const task = inflight.thisTurnModel ? `${inflight.thisTurnModel}` : 'Working...';
const node = atAddNode({ agentId: agentKey, task, status: 'running' });
if (node) {
_activeAgentNodeId = node.id;
targetNodeId = node.id;
}
}
}
const tc = atAddToolCall(targetNodeId, {
name: d.name,
args: d.args || {},
status: 'running',
});
if (tc) {
_lastToolId = tc.id;
}
}
// ─── Subagent lifecycle event handler (from SSE 'subagent' events) ────
function _atTrackSubagent(d: any): void {
if (!S.activityTree) initActivityTree();
const tree = S.activityTree!;
if (d.event_type === 'subagent.start') {
// Derive agentId from subagent_id if available, otherwise from goal hint.
// subagent_id format: "lotus-1" → agentId = "lotus"
let agentId = 'unknown';
if (d.subagent_id) {
// Strip numeric suffix e.g. "lotus-1" → "lotus"
agentId = d.subagent_id.replace(/-\d+$/, '');
}
const task = d.goal || d.preview || `Subagent ${d.task_index ?? ''}`.trim();
const node = atAddNode({ agentId, task, status: 'running' });
if (node) {
_activeAgentNodeId = node.id;
}
return;
}
if (d.event_type === 'subagent.complete' || d.event_type === 'subagent.done') {
// Find the running node for this subagent and finalize it.
// Use subagent_id to locate the right node.
let targetNodeId: string | null = null;
if (d.subagent_id) {
const agentId = d.subagent_id.replace(/-\d+$/, '');
const found = Object.values(tree.nodes).find(
(n: ActivityNode) => n.agentId === agentId && n.status === 'running'
);
if (found) targetNodeId = found.id;
}
// Fall back to _activeAgentNodeId if no subagent_id match
if (!targetNodeId) targetNodeId = _activeAgentNodeId;
if (targetNodeId && tree.nodes[targetNodeId]) {
const status = d.status === 'timeout' || d.status === 'error' ? 'error' : 'done';
atFinalizeNode(targetNodeId, status);
if (_activeAgentNodeId === targetNodeId) {
_activeAgentNodeId = null;
}
}
return;
}
}
function _atTrackToolComplete(d: any): void {
if (!S.activityTree) return;
const tree = S.activityTree!;
// Try to find the matching running tool call across all active nodes
const activeNodes = Object.values(tree.nodes).filter(
(n: ActivityNode) => n.status === 'running' || n.status === 'thinking'
);
for (const node of activeNodes) {
for (const tc of node.toolCalls) {
if (tc.status === 'running' && tc.name === d.name) {
atUpdateToolCall(node.id, tc.id, {
status: d.is_error ? 'error' : 'done',
result: d.preview || d.result,
duration: d.duration,
});
return;
}
}
}
}
function _atTrackDone(): void {
if (!S.activityTree) return;
// Finalize the active agent node
if (_activeAgentNodeId) {
atFinalizeNode(_activeAgentNodeId, 'done');
_activeAgentNodeId = null;
}
// Reset root status
const root = S.activityTree.nodes[S.activityTree.rootId];
if (root) {
root.status = 'running';
}
}
function _atTrackNewSession(): void {
_activeAgentNodeId = null;
_lastToolId = null;
if (S.activityTree) {
atReset();
}
}