Implements core pieces of 'Time Dilation in LLM Agent Systems'
(Dillenberg 2026) and adds OpenRouter as a second LLM provider.
ENGINE
- engine/time.py: AgentClock with cumulative proper time tau
(weighted by op type), EWMA pace (alpha=0.3, dt clamped 0.1-60s),
ClockRegistry singleton, gamma_{src->dst} frame transformation,
drift_report with per-pair divergence and threshold flag.
- engine/turn.py: ticks tau on reasoning/tool/memory/reactive;
broadcasts tau+pace+model in every WebSocket message.
- engine/db.py: schema adds turn_log.tau, turn_log.pace,
turn_log.model, agent_clocks table; dev-mode auto-migrate
drops+recreates if old schema detected.
- engine/llm.py: full refactor for two providers.
Ollama: native tool-calling via /api/chat
OpenRouter: OpenAI-compatible /api/v1/chat/completions
Auto mode picks OpenRouter if OPENROUTER_API_KEY is set.
Per-agent model via EMERGENCE_AGENT_<ID>_MODEL env var.
.env loader with empty-line guard.
decide_tool returns (name, args, meta) with cost_usd for OR.
FRONTEND
- web/: new 'Time Dilation · Eigenzeit tau' section with per-agent
tau bars, pace, op count. Drift warning when any pair exceeds
threshold. LLM provider info in header.
TESTS
- 14 new tests in tests/test_time.py (tau monotonic, EWMA convergence,
gamma asymmetry, drift detection).
- 4 new LLM tests: openrouter response parsing, per-agent override,
provider_info, is_available.
- All 99 tests green.
LIVE-VERIFIED
- 4 different OpenRouter models running in parallel:
- anchor: anthropic/claude-3.5-haiku
- flora: openai/gpt-4o-mini
- lovely: meta-llama/llama-3.3-70b-instruct
- spark: google/gemma-3-4b-it
- All 4 produce turns, all 4 have different tau values,
drift_report shows the Frame-Transformation gamma values.
- Observation: gamma ~ 1.00 because the explicit Round-Robin +
sleep(2) keeps frames coherent. This is itself a non-trivial
validation of the paper's claim: in non-synchronized systems,
dilation would emerge.
SECRETS
- .env added, OPENROUTER_API_KEY live. .env is git-ignored.
- .env.example documents the config without exposing any key.
- .gitignore now blocks .env, .env.local, *.key, *.pem.
README
- New 'Time Dilation' section explaining tau, pace, CDC, drift
- New 'Multi-LLM via OpenRouter' section with cost table
- Per-agent model config documented
293 lines
9.6 KiB
JavaScript
293 lines
9.6 KiB
JavaScript
// Emergence-Mini live view
|
||
const canvas = document.getElementById('world');
|
||
const ctx = canvas.getContext('2d');
|
||
const GRID = 240;
|
||
let snapshot = null;
|
||
let actions = [];
|
||
const AGENT_COLORS = {
|
||
anchor: '#ffd166',
|
||
flora: '#6cf0c2',
|
||
lovely: '#ff8fb1',
|
||
spark: '#82aaff',
|
||
};
|
||
const LANDMARK_COLORS = {
|
||
town_hall: '#ff6c6c',
|
||
library: '#82aaff',
|
||
plaza: '#ff8fb1',
|
||
park: '#6cf08a',
|
||
victory_arch: '#ffd166',
|
||
police: '#cccccc',
|
||
bookworm: '#a47cff',
|
||
cafe: '#ffb066',
|
||
billboard: '#dddddd',
|
||
techhub: '#6cf0c2',
|
||
home_anchor: '#3a3a3a',
|
||
home_flora: '#3a3a3a',
|
||
home_lovely: '#3a3a3a',
|
||
home_spark: '#3a3a3a',
|
||
};
|
||
|
||
function draw() {
|
||
if (!snapshot) return;
|
||
const W = canvas.width, H = canvas.height;
|
||
ctx.fillStyle = '#060a0e';
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
// grid
|
||
ctx.strokeStyle = '#0e1620';
|
||
ctx.lineWidth = 1;
|
||
for (let i = 0; i <= GRID; i += 30) {
|
||
const px = (i / GRID) * W;
|
||
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(0, px); ctx.lineTo(W, px); ctx.stroke();
|
||
}
|
||
|
||
// landmarks
|
||
for (const lm of snapshot.landmarks) {
|
||
const x = (lm.x / GRID) * W;
|
||
const y = (lm.y / GRID) * H;
|
||
const isHome = lm.id.startsWith('home_');
|
||
ctx.fillStyle = LANDMARK_COLORS[lm.id] || '#888';
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, isHome ? 5 : 7, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
if (!isHome) {
|
||
ctx.fillStyle = '#0a1018';
|
||
ctx.font = '10px ui-monospace';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(lm.name, x, y - 9);
|
||
}
|
||
}
|
||
|
||
// agents
|
||
for (const a of snapshot.agents) {
|
||
const x = (a.x / GRID) * W;
|
||
const y = (a.y / GRID) * H;
|
||
ctx.fillStyle = AGENT_COLORS[a.id] || '#fff';
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 8, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.fillStyle = '#0a1018';
|
||
ctx.font = 'bold 10px ui-monospace';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(a.name[0], x, y + 3);
|
||
// energy halo
|
||
ctx.strokeStyle = '#ffd166';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 8 + (100 - a.energy) * 0.06, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
function refreshAgentCards() {
|
||
const wrap = document.getElementById('agentList');
|
||
wrap.innerHTML = '';
|
||
for (const a of snapshot.agents) {
|
||
const div = document.createElement('div');
|
||
div.className = 'agent-card';
|
||
div.innerHTML = `
|
||
<h3>${a.name} <small>· ${a.role}</small></h3>
|
||
<div>at (${a.x}, ${a.y}) · ${a.mood}</div>
|
||
<div class="bar energy"><i style="width:${a.energy}%"></i></div>
|
||
<div class="bar knowledge"><i style="width:${a.knowledge}%"></i></div>
|
||
<div class="bar influence"><i style="width:${a.influence}%"></i></div>
|
||
<div class="bar credits"><i style="width:${Math.min(100, a.credits * 5)}%"></i></div>
|
||
<small>${a.energy.toFixed(0)}E · ${a.knowledge.toFixed(0)}K · ${a.influence.toFixed(0)}I · ${a.credits.toFixed(1)} CC</small>
|
||
`;
|
||
wrap.appendChild(div);
|
||
}
|
||
}
|
||
|
||
function refreshClocks() {
|
||
if (!snapshot || !snapshot.clocks) return;
|
||
const wrap = document.getElementById('clocks');
|
||
wrap.innerHTML = '';
|
||
const entries = Object.entries(snapshot.clocks);
|
||
// sort by τ descending
|
||
entries.sort((a, b) => b[1].tau - a[1].tau);
|
||
const maxTau = Math.max(1, entries.length ? entries[0][1].tau : 1);
|
||
const colorFor = { anchor: '#ffd166', flora: '#6cf0c2', lovely: '#ff8fb1', spark: '#82aaff' };
|
||
for (const [aid, c] of entries) {
|
||
const div = document.createElement('div');
|
||
div.className = 'clock-card';
|
||
const pct = (c.tau / maxTau) * 100;
|
||
div.innerHTML = `
|
||
<div class="name" style="color:${colorFor[aid] || '#fff'}">${aid}</div>
|
||
<div class="tau-bar"><i style="width:${pct}%; background:${colorFor[aid] || '#fff'}"></i></div>
|
||
<div class="meta">τ = ${c.tau.toFixed(2)} · pace = ${c.pace.toFixed(2)} op/s · ${c.n_ops} ops</div>
|
||
`;
|
||
wrap.appendChild(div);
|
||
}
|
||
}
|
||
|
||
function refreshDrift() {
|
||
if (!snapshot || !snapshot.drift) return;
|
||
const wrap = document.getElementById('drift');
|
||
const d = snapshot.drift;
|
||
if (!d.pairs || d.pairs.length === 0) {
|
||
wrap.innerHTML = '<small>No multi-agent drift yet.</small>';
|
||
return;
|
||
}
|
||
const top = d.pairs[0];
|
||
if (top.divergent) {
|
||
wrap.className = 'drift-warn';
|
||
wrap.innerHTML = `<b>⚠ DILATION DRIFT</b> · ${top.a}↔${top.b}: |Δτ| = ${top.drift.toFixed(1)} (γ=${top.gamma_ab})<br>
|
||
<small>${top.a} has experienced ${top.tau_a} units, ${top.b} ${top.tau_b}. Frame transformation exceeds threshold ${d.threshold}.</small>`;
|
||
} else {
|
||
wrap.className = 'drift-ok';
|
||
wrap.innerHTML = `<b>✓ COHERENT</b> · max drift = ${d.max_drift.toFixed(2)} (threshold ${d.threshold})`;
|
||
}
|
||
}
|
||
|
||
function refreshProposals() {
|
||
const wrap = document.getElementById('proposals');
|
||
wrap.innerHTML = '';
|
||
fetch('/api/proposals').then(r => r.json()).then(d => {
|
||
if (!d.active.length) {
|
||
wrap.innerHTML = '<small>No active proposals.</small>';
|
||
return;
|
||
}
|
||
for (const p of d.active) {
|
||
const el = document.createElement('div');
|
||
el.className = 'proposal';
|
||
el.innerHTML = `<h4>#${p.id} ${p.title}</h4>
|
||
<small>by ${p.author} · ${p.category} · ${p.votes} votes</small>
|
||
<p>${p.body}</p>`;
|
||
wrap.appendChild(el);
|
||
}
|
||
});
|
||
}
|
||
|
||
function refreshConstitution() {
|
||
const wrap = document.getElementById('constitution');
|
||
wrap.innerHTML = '';
|
||
fetch('/api/constitution').then(r => r.json()).then(c => {
|
||
for (const a of c.articles) {
|
||
const el = document.createElement('div');
|
||
el.className = 'article';
|
||
el.innerHTML = `<b>Article ${a.id} — ${a.title}</b><p>${a.body}</p>`;
|
||
wrap.appendChild(el);
|
||
}
|
||
});
|
||
}
|
||
|
||
function addFeed(entry) {
|
||
const ul = document.getElementById('feed');
|
||
const li = document.createElement('li');
|
||
const t = new Date().toLocaleTimeString();
|
||
if (entry.type === 'action') {
|
||
li.innerHTML = `<span class="who">${entry.name}</span>
|
||
<span class="tool">${entry.tool}</span>
|
||
<span class="why">— ${entry.rationale || ''}</span>
|
||
<small> · ${t}</small>`;
|
||
} else {
|
||
li.innerHTML = `<small>${entry.type} · ${t}</small>`;
|
||
}
|
||
ul.prepend(li);
|
||
while (ul.children.length > 80) ul.removeChild(ul.lastChild);
|
||
}
|
||
|
||
function refreshHeader() {
|
||
document.getElementById('tick').textContent = snapshot?.tick || 0;
|
||
document.getElementById('agentCount').textContent = snapshot?.agents.length || 0;
|
||
}
|
||
|
||
async function refreshAll() {
|
||
const r = await fetch('/api/state');
|
||
snapshot = await r.json();
|
||
draw();
|
||
refreshHeader();
|
||
refreshAgentCards();
|
||
refreshClocks();
|
||
refreshDrift();
|
||
refreshProposals();
|
||
refreshConstitution();
|
||
// LLM info
|
||
if (snapshot.llm) {
|
||
const info = document.getElementById('llmInfo');
|
||
info.textContent = `${snapshot.llm.provider} · ${snapshot.llm.model}`;
|
||
}
|
||
}
|
||
|
||
function connectWS() {
|
||
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws');
|
||
const status = document.getElementById('wsStatus');
|
||
ws.onopen = () => { status.textContent = 'live'; status.className = 'ws-status connected'; };
|
||
ws.onclose = () => {
|
||
status.textContent = 'disconnected — retrying'; status.className = 'ws-status disconnected';
|
||
setTimeout(connectWS, 2000);
|
||
};
|
||
ws.onmessage = (ev) => {
|
||
const msg = JSON.parse(ev.data);
|
||
if (msg.type === 'snapshot') {
|
||
snapshot = msg.data;
|
||
draw(); refreshHeader(); refreshAgentCards();
|
||
} else if (msg.type === 'action') {
|
||
// update local agent snapshot
|
||
if (snapshot) {
|
||
const a = snapshot.agents.find(x => x.id === msg.agent);
|
||
if (a) {
|
||
a.x = msg.x; a.y = msg.y;
|
||
a.energy = msg.energy; a.knowledge = msg.knowledge;
|
||
a.influence = msg.influence; a.credits = msg.credits;
|
||
a.mood = msg.mood;
|
||
}
|
||
draw(); refreshAgentCards();
|
||
}
|
||
addFeed(msg);
|
||
// refresh proposals / constitution occasionally
|
||
if (msg.tool && msg.tool.startsWith('vote_on') || msg.tool === 'submit_townhall_proposal') {
|
||
refreshProposals();
|
||
}
|
||
if (msg.tool === 'submit_townhall_proposal') {
|
||
setTimeout(refreshConstitution, 500);
|
||
}
|
||
} else if (msg.type === 'tick') {
|
||
document.getElementById('tick').textContent = msg.tick;
|
||
if (msg.clocks) snapshot.clocks = msg.clocks;
|
||
if (msg.drift) snapshot.drift = msg.drift;
|
||
refreshClocks();
|
||
refreshDrift();
|
||
}
|
||
};
|
||
}
|
||
|
||
async function populateManual() {
|
||
const a = await (await fetch('/api/agents')).json();
|
||
const selA = document.querySelector('#manual select[name=agent]');
|
||
for (const ag of a) {
|
||
const o = document.createElement('option');
|
||
o.value = ag.id; o.textContent = ag.name; selA.appendChild(o);
|
||
}
|
||
const tools = [
|
||
'go_to_place','go_home','say_to_agent','speak_to_all',
|
||
'show_emoticon','recharge_energy','add_to_longterm_memory',
|
||
'write_blog','add_to_billboard','submit_townhall_proposal',
|
||
'vote_on_proposal','idle',
|
||
];
|
||
const selT = document.querySelector('#manual select[name=tool]');
|
||
for (const t of tools) {
|
||
const o = document.createElement('option');
|
||
o.value = t; o.textContent = t; selT.appendChild(o);
|
||
}
|
||
}
|
||
|
||
document.getElementById('manual').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const f = e.target;
|
||
let args = {};
|
||
try { args = JSON.parse(f.args.value || '{}'); } catch { alert('args must be JSON'); return; }
|
||
const body = { tool: f.tool.value, args };
|
||
const r = await fetch(`/api/turn/${f.agent.value}`, {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(body),
|
||
});
|
||
const out = await r.json();
|
||
console.log('manual turn result', out);
|
||
});
|
||
|
||
refreshAll();
|
||
populateManual();
|
||
connectWS();
|
||
setInterval(refreshProposals, 5000);
|