emergence-mini-dilles/engine/turn.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

180 lines
6.7 KiB
Python

"""Turn manager: round-robin + reactive triggers + τ-tracking."""
import json
import time
import threading
import queue
from . import agents as agents_mod
from . import needs
from . import tools
from . import world
from . import reasoning
from . import governance
from . import db
from . import time as time_mod
class Engine:
"""Holds the simulation loop and a state-change broadcast queue."""
def __init__(self):
self.tick = 0
self.broadcasts: "queue.Queue[dict]" = queue.Queue()
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._speak_events: list[dict] = []
# -------- Loop control --------
def start(self):
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self):
self._stop.set()
# -------- Main loop --------
def _run(self):
tools.bootstrap()
while not self._stop.is_set():
self._one_round()
time.sleep(2.0) # 2s per tick
def _one_round(self):
self.tick += 1
db.set_world_state("tick", self.tick)
needs.tick_all_needs()
for a in agents_mod.all_agents():
self._agent_turn(a)
governance.apply_accepted_proposals_to_constitution()
# Broadcast a per-round tick summary including the time-dilation
# report so the UI can render the τ-timeline + drift warnings.
self._broadcast({
"type": "tick",
"tick": self.tick,
"clocks": time_mod.registry.snapshot_all(),
"drift": time_mod.registry.drift_report(),
})
def _agent_turn(self, agent):
ctx = {"speak_events": self._speak_events}
# Mark this as a reasoning step in τ — the LLM call IS the agent's
# internal experience, so we tick before deciding.
time_mod.record_reasoning(agent["id"])
tool_name, args, rationale = reasoning.decide(agent)
tool = tools.get(tool_name)
if not tool:
self._record_turn(agent["id"], tool_name, args,
{"ok": False, "error": "tool not found"})
return
at_lm = world.landmark_at(agent["x"], agent["y"])
if not tool.available_for(agent, at_lm):
self._record_turn(agent["id"], "idle", {}, {"ok": True, "fallback": True})
return
result = tool.handler(agent, args, ctx) if tool.handler else {"ok": False, "error": "no handler"}
# The tool execution itself is a tool-call operation in τ
time_mod.record_tool_call(agent["id"])
# Some tools (memory) trigger additional lookups — log them too
if tool_name == "add_to_longterm_memory":
time_mod.record_memory_lookup(agent["id"])
meta = reasoning.get_last_decision()
self._record_turn(agent["id"], tool_name, args, result,
model=meta.get("model"))
a2 = agents_mod.get(agent["id"])
if a2:
clock = time_mod.registry.get(agent["id"])
self._broadcast({
"type": "action",
"agent": a2["id"],
"name": a2["name"],
"tool": tool_name,
"args": args,
"result": result,
"rationale": rationale,
"x": a2["x"], "y": a2["y"],
"energy": a2["energy"], "knowledge": a2["knowledge"],
"influence": a2["influence"], "credits": a2["credits"],
"mood": a2["mood"],
# Time-Dilation fields
"tau": round(clock.tau, 3),
"pace": round(clock.pace, 4),
"model": meta.get("model"),
"decision_mode": meta.get("mode"),
"decision_latency_s": round(meta.get("latency_s", 0.0), 2),
})
self._handle_reactive(a2 or agent)
def _handle_reactive(self, speaker):
events = list(self._speak_events)
self._speak_events.clear()
if not events:
return
for ev in events:
if not ev.get("public") and ev.get("to") is None:
continue
nearby = world.nearby_agents(speaker["id"], ev["x"], ev["y"])
for listener in nearby[:4]:
self._reaction_turn(listener, ev)
def _reaction_turn(self, listener, speech):
text = speech.get("text", "")
if not text:
return
# Mark the reaction as a low-weight reasoning step in τ
time_mod.record_reactive(listener["id"])
if any(t in (listener.get("personality") or []) for t in
["warm", "expressive", "cooperative"]):
reply = f"Acknowledged: {text[:24]}"
ctx = {"speak_events": []}
tools.get("say_to_agent").handler(
listener,
{"target": speech["from"], "text": reply},
ctx,
)
def _record_turn(self, agent_id, tool, args, result, model: str | None = None):
clock = time_mod.registry.get(agent_id)
db.log_turn(agent_id, tool, args, result,
tau=clock.tau, pace=clock.pace, model=model)
def _broadcast(self, message: dict):
self.broadcasts.put(message)
db.log_event("engine", message.get("type", "info"), message)
# -------- Manual trigger (for tests / forced turns) --------
def force_turn(self, agent_id: str, tool_name: str, args: dict):
agent = agents_mod.get(agent_id)
if not agent:
return {"ok": False, "error": "no such agent"}
tool = tools.get(tool_name)
if not tool:
return {"ok": False, "error": "no such tool"}
ctx = {"speak_events": self._speak_events}
time_mod.record_reasoning(agent_id)
result = tool.handler(agent, args, ctx)
time_mod.record_tool_call(agent_id)
clock = time_mod.registry.get(agent_id)
self._record_turn(agent_id, tool_name, args, result)
a2 = agents_mod.get(agent_id)
meta = reasoning.get_last_decision()
self._broadcast({
"type": "action", "agent": a2["id"], "name": a2["name"],
"tool": tool_name, "args": args, "result": result,
"rationale": "forced",
"x": a2["x"], "y": a2["y"],
"energy": a2["energy"], "knowledge": a2["knowledge"],
"influence": a2["influence"], "credits": a2["credits"],
"mood": a2["mood"],
"tau": round(clock.tau, 3),
"pace": round(clock.pace, 4),
"model": meta.get("model"),
})
return result
engine = Engine()