emergence-mini-dilles/server.py
Jeuners 919866e50d Time Dilation framework + OpenRouter multi-LLM
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
2026-06-15 02:27:11 +02:00

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