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
177 lines
4.7 KiB
Python
177 lines
4.7 KiB
Python
"""FastAPI server for Emergence-Mini.
|
|
|
|
Endpoints:
|
|
- GET /api/state -> full world snapshot
|
|
- GET /api/agents -> list agents
|
|
- GET /api/landmarks -> list landmarks
|
|
- GET /api/proposals -> active + recent proposals
|
|
- GET /api/constitution -> current constitution
|
|
- GET /api/events -> recent events (billboard, blog, etc.)
|
|
- GET /api/memories/{agent_id} -> an agent's memories
|
|
- GET /api/blogs -> recent blog posts
|
|
- POST /api/turn/{agent_id} -> force a tool call (manual control)
|
|
- WS /ws -> live state stream
|
|
- GET / -> serves the SPA
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from engine import db, world, agents as agents_mod, governance, tools, llm as llm_mod
|
|
from engine.turn import engine as sim_engine
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
WEB = ROOT / "web"
|
|
|
|
app = FastAPI(title="Emergence-Mini", version="0.1.0")
|
|
|
|
|
|
def _bootstrap():
|
|
db.init_db()
|
|
world.bootstrap()
|
|
agents_mod.bootstrap()
|
|
tools.bootstrap()
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def on_startup():
|
|
_bootstrap()
|
|
if not os.environ.get("EMERGENCE_TEST_MODE"):
|
|
sim_engine.start()
|
|
|
|
|
|
@app.on_event("shutdown")
|
|
async def on_shutdown():
|
|
sim_engine.stop()
|
|
|
|
|
|
# -------- Static / SPA --------
|
|
|
|
app.mount("/static", StaticFiles(directory=str(WEB)), name="static")
|
|
|
|
|
|
@app.get("/")
|
|
async def index():
|
|
return FileResponse(str(WEB / "index.html"))
|
|
|
|
|
|
# -------- Helpers --------
|
|
|
|
def _query(sql: str, params=()):
|
|
c = sqlite3.connect(db.DB_PATH, check_same_thread=False)
|
|
c.row_factory = sqlite3.Row
|
|
try:
|
|
return [dict(r) for r in c.execute(sql, params).fetchall()]
|
|
finally:
|
|
c.close()
|
|
|
|
|
|
# -------- API --------
|
|
|
|
@app.get("/api/state")
|
|
async def state():
|
|
from engine import time as time_mod
|
|
return {
|
|
"tick": db.get_world_state("tick", 0),
|
|
"started_at": db.get_world_state("started_at"),
|
|
"grid": {"w": world.GRID_W, "h": world.GRID_H},
|
|
"agents": agents_mod.all_agents(),
|
|
"landmarks": world.list_landmarks(),
|
|
"constitution": governance.load_constitution(),
|
|
"llm": llm_mod.provider_info(),
|
|
"clocks": time_mod.registry.snapshot_all(),
|
|
"drift": time_mod.registry.drift_report(),
|
|
}
|
|
|
|
|
|
@app.get("/api/agents")
|
|
async def agents():
|
|
return agents_mod.all_agents()
|
|
|
|
|
|
@app.get("/api/landmarks")
|
|
async def landmarks():
|
|
return world.list_landmarks()
|
|
|
|
|
|
@app.get("/api/proposals")
|
|
async def proposals():
|
|
return {
|
|
"active": governance.active_proposals(),
|
|
"recent": governance.all_proposals(),
|
|
}
|
|
|
|
|
|
@app.get("/api/constitution")
|
|
async def constitution():
|
|
return governance.load_constitution()
|
|
|
|
|
|
@app.get("/api/events")
|
|
async def events():
|
|
return _query("SELECT * FROM events ORDER BY id DESC LIMIT 100")
|
|
|
|
|
|
@app.get("/api/memories/{agent_id}")
|
|
async def memories(agent_id: str):
|
|
return _query(
|
|
"SELECT * FROM memories WHERE agent_id=? ORDER BY id DESC LIMIT 50",
|
|
(agent_id,),
|
|
)
|
|
|
|
|
|
@app.get("/api/blogs")
|
|
async def blogs():
|
|
rows = _query("SELECT * FROM bills ORDER BY id DESC LIMIT 50")
|
|
out = []
|
|
for r in rows:
|
|
try:
|
|
payload = json.loads(r["body"])
|
|
out.append({"id": r["id"], "ts": r["ts"], **payload})
|
|
except Exception:
|
|
out.append({"id": r["id"], "ts": r["ts"], "title": "Untitled", "body": r["body"]})
|
|
return out
|
|
|
|
|
|
@app.post("/api/turn/{agent_id}")
|
|
async def force_turn(agent_id: str, body: dict):
|
|
tool_name = body.get("tool")
|
|
args = body.get("args", {})
|
|
if not tool_name:
|
|
return JSONResponse({"ok": False, "error": "tool required"}, status_code=400)
|
|
result = sim_engine.force_turn(agent_id, tool_name, args)
|
|
# If a voting turn just closed a proposal, apply it to the constitution
|
|
if tool_name == "vote_on_proposal":
|
|
from engine import governance
|
|
governance.apply_accepted_proposals_to_constitution()
|
|
return result
|
|
|
|
|
|
# -------- WebSocket --------
|
|
|
|
@app.websocket("/ws")
|
|
async def ws(ws: WebSocket):
|
|
await ws.accept()
|
|
queue = sim_engine.broadcasts
|
|
try:
|
|
# initial snapshot
|
|
await ws.send_json({"type": "snapshot", "data": await state()})
|
|
while True:
|
|
try:
|
|
msg = await asyncio.to_thread(queue.get, timeout=30.0)
|
|
await ws.send_json(msg)
|
|
except WebSocketDisconnect:
|
|
break
|
|
except Exception:
|
|
# client went away — stop sending
|
|
break
|
|
except WebSocketDisconnect:
|
|
pass
|