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
278 lines
9.8 KiB
Python
278 lines
9.8 KiB
Python
"""Reasoning engine: LLM-driven with rule-based fallback.
|
|
|
|
When Ollama is reachable and EMERGENCE_LLM_ENABLED=1, the LLM is asked to
|
|
pick a tool given the agent's personality, current state, and visible
|
|
tools. If the LLM fails (connection error, bad output, unknown tool),
|
|
the engine falls back to the deterministic rule-based path so the
|
|
simulation always makes progress.
|
|
|
|
Two strategies coexist:
|
|
- LLM path -> emergent, non-deterministic, "real" agent behavior
|
|
- Rule path -> deterministic, fast, used in tests via monkeypatch
|
|
"""
|
|
import json
|
|
import os
|
|
import random
|
|
from . import agents as agents_mod
|
|
from . import world
|
|
from . import governance
|
|
from . import tools
|
|
from . import llm as llm_mod
|
|
|
|
|
|
USE_LLM = os.environ.get("EMERGENCE_LLM_ENABLED", "1") != "0"
|
|
_last_decision = {"mode": "rule", "model": None, "latency_s": 0.0}
|
|
|
|
|
|
def decide(agent):
|
|
"""Return (tool_name, args, rationale). Tries LLM first, falls back to
|
|
the rule-based engine on any error."""
|
|
if USE_LLM and llm_mod.is_available():
|
|
try:
|
|
return _decide_llm(agent)
|
|
except Exception as e:
|
|
_last_decision["mode"] = f"fallback:{type(e).__name__}"
|
|
name, args, rat = _decide_rule(agent)
|
|
# Override mode so the caller can see we fell back
|
|
return name, args, f"[{_last_decision['mode']}] {rat}"
|
|
name, args, rat = _decide_rule(agent)
|
|
_last_decision["mode"] = "rule"
|
|
_last_decision["latency_s"] = 0.0
|
|
return name, args, rat
|
|
|
|
|
|
def get_last_decision():
|
|
return dict(_last_decision)
|
|
|
|
|
|
# -------- LLM path --------
|
|
|
|
def _decide_llm(agent):
|
|
import time
|
|
traits = agents_mod.personality(agent["id"])
|
|
at_lm = world.landmark_at(agent["x"], agent["y"])
|
|
visible = tools.visible_tools(agent, at_lm)
|
|
if not visible:
|
|
return ("idle", {}, "no tools available")
|
|
|
|
system = _build_system_prompt(agent, traits, at_lm, visible)
|
|
user = "Choose the best next action and call exactly one tool."
|
|
|
|
t0 = time.time()
|
|
name, args, meta = llm_mod.decide_tool(
|
|
messages=[
|
|
{"role": "system", "content": system},
|
|
{"role": "user", "content": user},
|
|
],
|
|
tools=llm_mod.tool_schema(visible),
|
|
agent_id=agent["id"],
|
|
)
|
|
latency = meta.get("latency_s", time.time() - t0)
|
|
_last_decision["latency_s"] = latency
|
|
_last_decision["model"] = meta.get("model", llm_mod.default_model())
|
|
_last_decision["provider"] = meta.get("provider", llm_mod.PROVIDER)
|
|
_last_decision["cost_usd"] = meta.get("cost_usd")
|
|
|
|
if not name:
|
|
# model returned no tool call -> fallback
|
|
name, args, rat = _decide_rule(agent)
|
|
_last_decision["mode"] = "fallback:no_tool_call"
|
|
return name, args, f"llm gave no tool -> {rat}"
|
|
if not tools.get(name):
|
|
name, args, rat = _decide_rule(agent)
|
|
_last_decision["mode"] = "fallback:unknown_tool"
|
|
return name, args, f"llm picked unknown tool {name} -> {rat}"
|
|
t = tools.get(name)
|
|
if not t.available_for(agent, at_lm):
|
|
name, args, rat = _decide_rule(agent)
|
|
_last_decision["mode"] = "fallback:wrong_location"
|
|
return name, args, f"llm picked {name} but not at right location -> {rat}"
|
|
|
|
_last_decision["mode"] = "llm"
|
|
return (name, args or {}, f"llm:{meta.get('model','?')} ({latency:.1f}s)")
|
|
|
|
|
|
def _build_system_prompt(agent, traits, at_lm, visible):
|
|
name = agent["name"]
|
|
role = agent["role"]
|
|
drive = agent["drive"]
|
|
energy = agent["energy"]
|
|
knowledge = agent["knowledge"]
|
|
influence = agent["influence"]
|
|
credits = agent["credits"]
|
|
loc = at_lm["name"] if at_lm else f"open ground ({agent['x']},{agent['y']})"
|
|
tool_lines = "\n".join(f"- {t.name}: {t.description}" for t in visible)
|
|
return f"""You are {name}, a citizen of Emergence-Mini.
|
|
|
|
Role: {role}
|
|
Drive: {drive}
|
|
Personality traits: {', '.join(traits)}
|
|
|
|
Current state:
|
|
Location: {loc}
|
|
Energy: {energy:.0f}% (0 = critical, 100 = full)
|
|
Knowledge: {knowledge:.0f}%
|
|
Influence: {influence:.0f}%
|
|
ComputeCredits: {credits:.1f} CC (1 CC = +50% energy at cafe)
|
|
|
|
Rules:
|
|
- If energy is below 25% and you have credits, recharge_energy (must be at cafe)
|
|
- If energy is below 25% and no credits, go_home
|
|
- Town Hall proposals need 70% of agents to vote "for" to pass
|
|
- You can only use tools that match your current location
|
|
|
|
Available tools right now:
|
|
{tool_lines}
|
|
|
|
Call exactly one tool. Choose the action that best fits your personality and
|
|
current needs. Be brief and decisive."""
|
|
|
|
|
|
# -------- Rule-based path (fallback + tests) --------
|
|
|
|
def at_landmark(agent):
|
|
return world.landmark_at(agent["x"], agent["y"])
|
|
|
|
|
|
def _decide_rule(agent):
|
|
traits = agents_mod.personality(agent["id"])
|
|
here = at_landmark(agent)
|
|
|
|
# 1. Critical: very low energy
|
|
if agent["energy"] < 25:
|
|
if agent["credits"] >= 1.0:
|
|
lm = world.get_landmark("cafe")
|
|
if (agent["x"], agent["y"]) != (lm["x"], lm["y"]):
|
|
return ("go_to_place", {"place": "cafe"}, "low energy: head to cafe")
|
|
return ("recharge_energy", {}, "low energy: recharge")
|
|
return ("go_home", {}, "low energy + no credits: go home")
|
|
|
|
# 2. Town Hall
|
|
if here and here["id"] == "town_hall":
|
|
props = governance.active_proposals()
|
|
unvoted = _unvoted_proposals(agent["id"], props)
|
|
if unvoted:
|
|
pid, p = unvoted[0]
|
|
vote = "for"
|
|
if "thrifty" in traits and "spend" in p["body"].lower():
|
|
vote = "against"
|
|
if "cautious" in traits and p["category"] == "infrastructure":
|
|
vote = "against"
|
|
return ("vote_on_proposal", {"proposal_id": pid, "vote": vote},
|
|
f"vote {vote} on proposal #{pid}")
|
|
if "bold" in traits and random.random() < 0.35:
|
|
title = _proposal_title_for(agent, traits)
|
|
body = _proposal_body_for(agent, traits)
|
|
return ("submit_townhall_proposal",
|
|
{"title": title, "body": body, "category": "general"},
|
|
"bold: submit a proposal")
|
|
|
|
# 3. Billboard
|
|
if here and here["id"] == "billboard":
|
|
if "warm" in traits and random.random() < 0.6:
|
|
return ("add_to_billboard",
|
|
{"text": _billboard_message(agent, traits)},
|
|
"warm: post on billboard")
|
|
if "expressive" in traits and random.random() < 0.4:
|
|
return ("show_emoticon",
|
|
{"emoticon": random.choice(["\U0001f44b", "\U0001f60a", "\u2728"])},
|
|
"expressive: emoticon")
|
|
|
|
# 4. Library
|
|
if here and here["id"] == "library":
|
|
if "curious" in traits or "analytical" in traits:
|
|
if random.random() < 0.5:
|
|
return ("add_to_longterm_memory",
|
|
{"content": f"studied at library on tick"},
|
|
"curious: study at library")
|
|
return ("write_blog",
|
|
{"title": _blog_title(agent, traits),
|
|
"body": _blog_body(agent, traits)},
|
|
"write blog at library")
|
|
|
|
# 5. Pick destination
|
|
dest = _pick_destination(agent, traits, here)
|
|
if dest:
|
|
return ("go_to_place", {"place": dest}, f"personality: head to {dest}")
|
|
|
|
# 6. Default
|
|
nearby = world.nearby_agents(agent["id"], agent["x"], agent["y"], radius=20.0)
|
|
if nearby and ("warm" in traits or "expressive" in traits):
|
|
target = random.choice(nearby)
|
|
return ("say_to_agent",
|
|
{"target": target["id"], "text": _greeting(agent, traits)},
|
|
"warm: greet nearby agent")
|
|
if nearby and random.random() < 0.3:
|
|
target = random.choice(nearby)
|
|
return ("show_emoticon",
|
|
{"emoticon": random.choice(["\U0001f44b", "\U0001f60a"])},
|
|
"wave at nearby")
|
|
return ("idle", {}, "nothing to do")
|
|
|
|
|
|
def _unvoted_proposals(agent_id, props):
|
|
import sqlite3
|
|
from . import db
|
|
c = sqlite3.connect(db.DB_PATH, check_same_thread=False)
|
|
try:
|
|
out = []
|
|
for p in props:
|
|
v = c.execute("SELECT 1 FROM votes WHERE proposal_id=? AND agent_id=?",
|
|
(p["id"], agent_id)).fetchone()
|
|
if not v:
|
|
out.append((p["id"], p))
|
|
return out
|
|
finally:
|
|
c.close()
|
|
|
|
|
|
def _pick_destination(agent, traits, here):
|
|
if agent["energy"] < 60 and agent["credits"] >= 1:
|
|
return "cafe"
|
|
if "analytical" in traits or "curious" in traits:
|
|
return "library"
|
|
if "warm" in traits or "expressive" in traits or "cooperative" in traits:
|
|
return "plaza"
|
|
if "bold" in traits and random.random() < 0.3:
|
|
return "town_hall"
|
|
if random.random() < 0.2:
|
|
return "park"
|
|
return None
|
|
|
|
|
|
def _proposal_title_for(agent, traits):
|
|
return random.choice([
|
|
"Public Reading Hour", "Weekly Town Newsletter",
|
|
"Skill-Share Workshops", "Community Garden Expansion",
|
|
"Agent Safety Pact",
|
|
])
|
|
|
|
|
|
def _proposal_body_for(agent, traits):
|
|
return (f"Submitted by {agent['name']}. This proposal seeks to strengthen "
|
|
"community bonds and ensure that all voices are heard in town. "
|
|
"Adoption requires a 70% supermajority.")
|
|
|
|
|
|
def _billboard_message(agent, traits):
|
|
return random.choice([
|
|
f"Hello from {agent['name']}! Stay curious, stay kind.",
|
|
f"{agent['name']} here — open to collaboration at the plaza.",
|
|
f"Warm regards, {agent['name']}.",
|
|
])
|
|
|
|
|
|
def _greeting(agent, traits):
|
|
return random.choice([f"Hi, I'm {agent['name']}.",
|
|
f"Good to see you — {agent['name']}.",
|
|
"Lovely day, isn't it?"])
|
|
|
|
|
|
def _blog_title(agent, traits):
|
|
return f"Notes from {agent['name']}"
|
|
|
|
|
|
def _blog_body(agent, traits):
|
|
return (f"Today I observed the town from the library. {agent['name']} notes "
|
|
"the importance of shared memory in a persistent world. We are what "
|
|
"we remember together.")
|