Phase 8: TypeScript migration, i18n rewrite, Activity Tree, Projects API, Heartbeats
This commit is contained in:
528
static/activity-tree.ts
Normal file
528
static/activity-tree.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
// ─── 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user