529 lines
18 KiB
TypeScript
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();
|
|
}
|
|
}
|