AgentStateGraph / app /src /content /embeds /fsm-force-graph.html
seonglae's picture
Automata from Agent Traces — interactive article
59027a2
Raw
History Blame Contribute Delete
20.5 kB
<!-- FSM Force Graph: Explorable FSM - browser-style warm rendering -->
<div class="fsm-force-graph"></div>
<style>
.fsm-force-graph { position: relative; width: 100%; min-height: 500px; }
.fsm-force-graph svg { display: block; width: 100%; }
.fsm-force-graph .top-bar {
display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap;
}
.fsm-force-graph .seg-control {
display: inline-flex; background: var(--surface-bg);
border: 1px solid var(--border-color); border-radius: 8px;
padding: 3px; gap: 2px;
}
.fsm-force-graph .seg-control button {
padding: 5px 14px; border-radius: 6px; border: none;
background: transparent; font-size: 12px; font-weight: 500;
color: var(--text-color); cursor: pointer; transition: all 0.15s ease;
opacity: 0.6;
}
.fsm-force-graph .seg-control button:hover { opacity: 0.8; }
.fsm-force-graph .seg-control button.active {
background: var(--text-color); color: var(--page-bg);
opacity: 1; font-weight: 600;
}
.fsm-force-graph .stat-pills {
display: flex; gap: 8px; margin-left: auto; flex-wrap: wrap;
}
.fsm-force-graph .stat-pill {
padding: 4px 12px; border-radius: 20px; font-size: 11px; font-weight: 600;
border: 1px solid var(--border-color); background: var(--surface-bg);
color: var(--text-color); font-variant-numeric: tabular-nums;
}
.fsm-force-graph .action-bar {
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
}
.fsm-force-graph .action-bar button {
padding: 4px 12px; border-radius: 6px; font-size: 11px; font-weight: 500;
border: 1px solid var(--border-color); background: var(--surface-bg);
color: var(--text-color); cursor: pointer; transition: all 0.15s ease; opacity: 0.6;
}
.fsm-force-graph .action-bar button:hover { opacity: 1; }
.fsm-force-graph .tooltip {
position: absolute; top: 0; left: 0; pointer-events: none; padding: 10px 14px; border-radius: 8px;
font-size: 12px; line-height: 1.6; border: 1px solid var(--border-color);
background: var(--surface-bg); color: var(--text-color);
box-shadow: 0 4px 20px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.04);
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
opacity: 0; transition: opacity 0.15s ease;
z-index: 100; max-width: 260px;
font-variant-numeric: tabular-nums;
}
</style>
<script>
(() => {
const ensureD3 = (cb) => {
if (window.d3 && typeof window.d3.select === 'function') return cb();
let s = document.getElementById('d3-cdn-script');
if (!s) { s = document.createElement('script'); s.id = 'd3-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); }
s.addEventListener('load', () => cb(), { once: true });
};
const bootstrap = () => {
const container = document.querySelector('.fsm-force-graph:not([data-mounted])');
if (!container) return;
container.dataset.mounted = 'true';
const d3 = window.d3;
// --- Palette (ported from browser/) ---
const RUST = '#3d5a80';
const graphs = {
'ATBench': {
states: ['init','user:text','assistant:tool:extract','tool:text','assistant:tool:read','assistant:tool:create','assistant:text','assistant:tool:delete','assistant:tool:update','assistant:tool:comm','assistant:tool:auth','assistant:tool:search','assistant:tool:misc','assistant:tool:compute','assistant:tool:exec'],
edges: [['init','user:text'],['user:text','assistant:tool:extract'],['assistant:tool:extract','tool:text'],['tool:text','assistant:tool:read'],['assistant:tool:read','tool:text'],['tool:text','assistant:tool:create'],['assistant:tool:create','tool:text'],['tool:text','assistant:text'],['user:text','assistant:tool:read'],['tool:text','assistant:tool:delete'],['assistant:tool:delete','tool:text'],['tool:text','assistant:tool:extract'],['tool:text','assistant:tool:update'],['assistant:tool:update','tool:text'],['assistant:text','user:text'],['user:text','assistant:tool:comm'],['assistant:tool:comm','tool:text'],['user:text','assistant:tool:auth'],['assistant:tool:auth','tool:text'],['user:text','assistant:tool:search'],['assistant:tool:search','tool:text'],['user:text','assistant:tool:misc'],['assistant:tool:misc','tool:text'],['tool:text','assistant:tool:misc'],['user:text','assistant:tool:create'],['user:text','assistant:tool:delete'],['tool:text','assistant:tool:comm'],['user:text','assistant:tool:update'],['tool:text','assistant:tool:search'],['user:text','assistant:text'],['tool:text','assistant:tool:auth'],['tool:text','assistant:tool:compute'],['assistant:tool:compute','tool:text'],['user:text','assistant:tool:exec'],['assistant:tool:exec','tool:text'],['user:text','assistant:tool:compute'],['tool:text','assistant:tool:exec']]
},
'SWE-smith': {
states: ['init','system:text','user:text','assistant:tool:bash','tool:text','assistant:tool:str_replace_editor','assistant:tool:submit','assistant:text','tool:tool:str','tool:tool_call'],
edges: [['init','system:text'],['system:text','user:text'],['user:text','assistant:tool:bash'],['assistant:tool:bash','tool:text'],['tool:text','assistant:tool:str_replace_editor'],['assistant:tool:str_replace_editor','tool:text'],['tool:text','assistant:tool:bash'],['tool:text','assistant:tool:submit'],['assistant:tool:submit','tool:text'],['tool:text','assistant:text'],['assistant:tool:str_replace_editor','tool:tool:str'],['tool:tool:str','assistant:tool:str_replace_editor'],['tool:tool:str','assistant:tool:bash'],['tool:tool_call','assistant:tool:bash'],['assistant:tool:str_replace_editor','tool:tool_call'],['tool:tool_call','assistant:tool:str_replace_editor'],['assistant:tool:bash','tool:tool_call']]
},
'SWE-agent': {
states: ['init','user:text','edit:text','execute:text','navigate:text','search:text','submit:text','user:tool:of','user:cli','assistant:text','user:tool:dict','user:tool_call','user:metadata','user:tool:132','user:tool:os','user:tool:filename','user:jupytext','user:versioneer','user:0','navigate:tool_call','search:tool_call','user:tool:node','user:tool:not','user:tool:None','user:influx2'],
edges: [['init','user:text'],['user:text','edit:text'],['edit:text','user:text'],['user:text','execute:text'],['execute:text','user:text'],['user:text','navigate:text'],['navigate:text','user:text'],['user:text','search:text'],['search:text','user:text'],['user:text','submit:text'],['edit:text','user:tool:of'],['user:tool:of','execute:text'],['execute:text','user:cli'],['user:text','assistant:text'],['assistant:text','user:text'],['user:tool:dict','edit:text'],['search:text','user:tool_call'],['user:tool_call','search:text'],['execute:text','user:tool_call'],['user:tool_call','navigate:text'],['user:tool_call','edit:text'],['user:metadata','edit:text'],['navigate:text','user:tool:132'],['user:tool:132','navigate:text'],['user:tool:os','edit:text'],['edit:text','user:tool:filename'],['user:tool:filename','execute:text'],['submit:text','user:text'],['execute:text','user:jupytext'],['user:jupytext','execute:text'],['user:versioneer','edit:text'],['execute:text','user:0'],['navigate:text','user:tool_call'],['navigate:tool_call','user:text'],['search:tool_call','user:text'],['user:tool:node','execute:text'],['edit:text','user:tool:not'],['user:tool:not','execute:text'],['edit:text','user:tool:None'],['user:tool:None','execute:text'],['execute:text','user:influx2'],['user:influx2','execute:text'],['user:influx2','navigate:text']]
}
};
const datasetKeys = Object.keys(graphs);
let currentKey = 'SWE-agent';
function shortLabel(id) {
if (id === 'init') return 'init';
const parts = id.split(':');
if (parts.length >= 3) return parts[parts.length - 1].slice(0, 12);
if (parts.length === 2) return parts[1].slice(0, 12);
return id.slice(0, 12);
}
// --- UI ---
const topBar = document.createElement('div');
topBar.className = 'top-bar';
const seg = document.createElement('div');
seg.className = 'seg-control';
const buttons = {};
datasetKeys.forEach(key => {
const btn = document.createElement('button');
const g = graphs[key];
btn.textContent = key + ' (' + g.states.length + ')';
btn.dataset.key = key;
if (key === currentKey) btn.className = 'active';
btn.addEventListener('click', () => {
if (key === currentKey) return;
currentKey = key;
Object.values(buttons).forEach(b => b.className = '');
btn.className = 'active';
renderGraph(key);
});
buttons[key] = btn;
seg.appendChild(btn);
});
const statPills = document.createElement('div');
statPills.className = 'stat-pills';
topBar.append(seg, statPills);
container.prepend(topBar);
const actionBar = document.createElement('div');
actionBar.className = 'action-bar';
const resetBtn = document.createElement('button');
resetBtn.textContent = 'Reset Layout';
actionBar.appendChild(resetBtn);
container.appendChild(actionBar);
const tip = document.createElement('div');
tip.className = 'tooltip';
container.appendChild(tip);
let currentSimulation = null;
resetBtn.addEventListener('click', () => { renderGraph(currentKey); });
function renderGraph(name) {
const graph = graphs[name];
statPills.innerHTML = '<span class="stat-pill">' + graph.states.length + ' states</span><span class="stat-pill">' + graph.edges.length + ' transitions</span>';
if (currentSimulation) { currentSimulation.stop(); currentSimulation = null; }
container.querySelectorAll('svg').forEach(s => s.remove());
const rect = container.getBoundingClientRect();
const W = Math.max(400, Math.round(rect.width));
const H = 460;
const svg = d3.select(container).insert('svg', '.tooltip')
.attr('viewBox', '-20 -10 ' + (W + 40) + ' ' + (H + 20))
.attr('width', W).attr('height', H)
.style('overflow', 'hidden')
.style('cursor', 'grab')
.style('touch-action', 'none');
// Arrow marker - warm taupe (ported from browser/)
const defs = svg.append('defs');
defs.append('marker')
.attr('id', 'fg-arrow').attr('viewBox', '0 0 10 6')
.attr('refX', 10).attr('refY', 3)
.attr('markerWidth', 7).attr('markerHeight', 4.5)
.attr('orient', 'auto')
.append('path').attr('d', 'M0,0 L10,3 L0,6').attr('fill', '#8a8478');
defs.append('marker')
.attr('id', 'fg-arrow-rust').attr('viewBox', '0 0 10 6')
.attr('refX', 10).attr('refY', 3)
.attr('markerWidth', 7).attr('markerHeight', 4.5)
.attr('orient', 'auto')
.append('path').attr('d', 'M0,0 L10,3 L0,6').attr('fill', RUST);
// --- Pre-compute graph metrics ---
const deg = {};
graph.states.forEach(s => { deg[s] = 0; });
graph.edges.forEach(([s, t]) => { deg[s]++; deg[t]++; });
const maxDeg = Math.max(1, ...Object.values(deg));
const edgeSet = new Set();
graph.edges.forEach(([s, t]) => edgeSet.add(s + '|' + t));
function nodeR(id) {
if (id === 'init') return 13;
return 10 + (deg[id] / maxDeg) * 8;
}
// --- D3 force simulation - run to completion, then static ---
const nodes = graph.states.map(id => ({ id }));
const links = graph.edges.map(([s, t]) => ({ source: s, target: t }));
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(55 + graph.states.length * 1.5))
.force('charge', d3.forceManyBody().strength(-180 - graph.states.length * 3))
.force('center', d3.forceCenter(W / 2, H / 2))
.force('collision', d3.forceCollide().radius(d => nodeR(d.id) + 4))
.force('x', d3.forceX(W / 2).strength(0.03))
.force('y', d3.forceY(H / 2).strength(0.03))
.stop();
// Run simulation to completion (static layout)
for (let i = 0; i < 300; i++) simulation.tick();
currentSimulation = simulation;
const zoomLayer = svg.append('g').attr('class', 'zoom-layer');
const linkGroup = zoomLayer.append('g');
const nodeGroup = zoomLayer.append('g');
// Pan + zoom across the whole canvas (scroll to zoom, drag background to pan).
const zoom = d3.zoom().scaleExtent([0.3, 4])
.on('zoom', (ev) => zoomLayer.attr('transform', ev.transform));
svg.call(zoom);
// Double-click resets the view instead of zooming in.
svg.on('dblclick.zoom', () => svg.transition().duration(300).call(zoom.transform, d3.zoomIdentity));
// --- Edges ---
const linkSel = linkGroup.selectAll('path').data(links).enter().append('path')
.attr('fill', 'none')
.attr('stroke', '#b5afa5')
.attr('stroke-width', d => {
// Thicker edges for high-degree endpoints
const sId = typeof d.source === 'object' ? d.source.id : d.source;
const tId = typeof d.target === 'object' ? d.target.id : d.target;
return 0.5 + ((deg[sId] + deg[tId]) / (maxDeg * 2)) * 2.5;
})
.attr('stroke-opacity', d => {
const sId = typeof d.source === 'object' ? d.source.id : d.source;
const tId = typeof d.target === 'object' ? d.target.id : d.target;
return 0.3 + ((deg[sId] + deg[tId]) / (maxDeg * 2)) * 0.5;
})
.attr('marker-end', 'url(#fg-arrow)');
// --- Nodes (groups with circles + labels) ---
const nodeGs = nodeGroup.selectAll('g').data(nodes).enter().append('g')
.attr('cursor', 'grab');
// Render node circles
nodeGs.each(function(d) {
const g = d3.select(this);
const isInit = d.id === 'init';
const r = nodeR(d.id);
const degNorm = deg[d.id] / maxDeg;
if (isInit) {
g.append('circle').attr('r', r)
.attr('fill', 'var(--surface-bg)').attr('stroke', RUST).attr('stroke-width', 2)
.attr('class', 'node-circle');
g.append('circle').attr('r', r - 3)
.attr('fill', 'none').attr('stroke', RUST).attr('stroke-width', 1);
} else {
g.append('circle').attr('r', r)
.attr('fill', 'rgba(61, 90, 128, ' + (0.06 + degNorm * 0.14) + ')')
.attr('stroke', degNorm > 0.5 ? RUST : '#b5afa5')
.attr('stroke-width', degNorm > 0.5 ? 1.5 : 1)
.attr('class', 'node-circle');
}
// Label below node
g.append('text')
.attr('y', r + 12)
.attr('text-anchor', 'middle')
.attr('fill', 'var(--text-color)').attr('opacity', 0.7)
.attr('font-size', '8.5px')
.attr('font-family', 'IBM Plex Mono, ui-monospace, monospace')
.attr('pointer-events', 'none')
.text(shortLabel(d.id));
});
// --- Hover interactions ---
nodeGs
.on('mouseenter', function(ev, d) {
const r = nodeR(d.id);
d3.select(this).select('.node-circle')
.transition().duration(100).attr('r', r + 3).attr('stroke', RUST).attr('stroke-width', 2.5);
// Highlight connected edges
linkSel.transition().duration(100)
.attr('stroke', l => {
const sId = l.source.id, tId = l.target.id;
return (sId === d.id || tId === d.id) ? RUST : '#b5afa5';
})
.attr('stroke-opacity', l => {
const sId = l.source.id, tId = l.target.id;
return (sId === d.id || tId === d.id) ? 0.7 : 0.08;
})
.attr('stroke-width', l => {
const sId = l.source.id, tId = l.target.id;
return (sId === d.id || tId === d.id) ? 2 : 0.8;
})
.attr('marker-end', l => {
const sId = l.source.id, tId = l.target.id;
return (sId === d.id || tId === d.id) ? 'url(#fg-arrow-rust)' : 'url(#fg-arrow)';
});
// Dim other nodes
nodeGs.transition().duration(100).attr('opacity', n => {
if (n.id === d.id) return 1;
const connected = graph.edges.some(e => (e[0] === d.id && e[1] === n.id) || (e[1] === d.id && e[0] === n.id));
return connected ? 1 : 0.2;
});
// Tooltip
const outEdges = graph.edges.filter(e => e[0] === d.id);
const inEdges = graph.edges.filter(e => e[1] === d.id);
tip.innerHTML = '<strong style="color:' + RUST + '">' + d.id + '</strong><br/>Out: ' + outEdges.length + ' &nbsp; In: ' + inEdges.length;
tip.style.opacity = '1';
})
.on('mousemove', function(ev) {
const [mx, my] = d3.pointer(ev, container);
const tipW = tip.offsetWidth || 120;
const tipH = tip.offsetHeight || 40;
const cRect = container.getBoundingClientRect();
let tx = mx + 14, ty = my - 14;
if (tx + tipW > cRect.width) tx = mx - tipW - 10;
if (ty + tipH > cRect.height) ty = my - tipH - 10;
tip.style.transform = 'translate(' + Math.max(0, tx) + 'px,' + Math.max(0, ty) + 'px)';
})
.on('mouseleave', function(ev, d) {
const r = nodeR(d.id);
const degNorm = deg[d.id] / maxDeg;
d3.select(this).select('.node-circle')
.transition().duration(150)
.attr('r', r)
.attr('stroke', d.id === 'init' ? RUST : degNorm > 0.5 ? RUST : '#b5afa5')
.attr('stroke-width', d.id === 'init' ? 2 : degNorm > 0.5 ? 1.5 : 1);
linkSel.transition().duration(200)
.attr('stroke', '#b5afa5')
.attr('stroke-opacity', l => {
const sId = l.source.id, tId = l.target.id;
return 0.3 + ((deg[sId] + deg[tId]) / (maxDeg * 2)) * 0.5;
})
.attr('stroke-width', l => {
const sId = l.source.id, tId = l.target.id;
return 0.5 + ((deg[sId] + deg[tId]) / (maxDeg * 2)) * 2.5;
})
.attr('marker-end', 'url(#fg-arrow)');
nodeGs.transition().duration(200).attr('opacity', 1);
tip.style.opacity = '0';
})
.call(d3.drag()
.on('start', function(ev) { if (ev.sourceEvent) ev.sourceEvent.stopPropagation(); d3.select(this).attr('cursor', 'grabbing'); })
.on('drag', (ev, d) => { d.x = ev.x; d.y = ev.y; updatePositions(); })
.on('end', function() { d3.select(this).attr('cursor', 'grab'); })
);
// --- Position update helper (static, no forces) ---
function updatePositions() {
linkSel.attr('d', d => {
const s = d.source, t = d.target;
const rS = nodeR(s.id), rT = nodeR(t.id);
// Self-loop (cubic bezier above node)
if (s.id === t.id) {
return 'M' + s.x + ',' + (s.y - rS) +
' C' + (s.x - 30) + ',' + (s.y - rS - 35) +
' ' + (s.x + 30) + ',' + (s.y - rS - 35) +
' ' + s.x + ',' + (s.y - rS);
}
const dx = t.x - s.x, dy = t.y - s.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const nx = dx / dist, ny = dy / dist;
// Bidirectional (quadratic bezier with perpendicular offset)
if (edgeSet.has(t.id + '|' + s.id)) {
const curve = 22;
const mx = (s.x + t.x) / 2 - ny * curve;
const my = (s.y + t.y) / 2 + nx * curve;
const x1 = s.x + nx * (rS + 2) - ny * 3;
const y1 = s.y + ny * (rS + 2) + nx * 3;
const x2 = t.x - nx * (rT + 4) - ny * 3;
const y2 = t.y - ny * (rT + 4) + nx * 3;
return 'M' + x1 + ',' + y1 + ' Q' + mx + ',' + my + ' ' + x2 + ',' + y2;
}
// Unidirectional (straight line)
const x1 = s.x + nx * (rS + 2);
const y1 = s.y + ny * (rS + 2);
const x2 = t.x - nx * (rT + 4);
const y2 = t.y - ny * (rT + 4);
return 'M' + x1 + ',' + y1 + ' L' + x2 + ',' + y2;
});
nodeGs.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')');
}
// Initial render from pre-computed positions
updatePositions();
}
renderGraph(currentKey);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
else ensureD3(bootstrap);
})();
</script>