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
This commit is contained in:
parent
8a52e3dfa3
commit
919866e50d
14 changed files with 882 additions and 129 deletions
24
.env.example
Normal file
24
.env.example
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Emergence-Mini environment configuration
|
||||||
|
# COPY this file to .env and fill in your own values.
|
||||||
|
# .env is git-ignored; do not commit secrets.
|
||||||
|
|
||||||
|
# OpenRouter API key (https://openrouter.ai/keys)
|
||||||
|
# Required when EMERGENCE_LLM_PROVIDER=openrouter
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
|
||||||
|
# Provider: "ollama" | "openrouter" | "auto" (default: auto)
|
||||||
|
# auto = use OpenRouter if OPENROUTER_API_KEY is set, else Ollama
|
||||||
|
EMERGENCE_LLM_PROVIDER=auto
|
||||||
|
|
||||||
|
# Default model when using OpenRouter (can be overridden per-agent)
|
||||||
|
EMERGENCE_OPENROUTER_MODEL=anthropic/claude-3.5-haiku
|
||||||
|
|
||||||
|
# Default model when using Ollama
|
||||||
|
EMERGENCE_OLLAMA_MODEL=llama3.2:3b
|
||||||
|
|
||||||
|
# Ollama server
|
||||||
|
EMERGENCE_LLM_URL=http://127.0.0.1:11434
|
||||||
|
|
||||||
|
# Master switch: 0 forces rule-based engine
|
||||||
|
EMERGENCE_LLM_ENABLED=1
|
||||||
|
EMERGENCE_LLM_TIMEOUT=30
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -18,6 +18,13 @@ venv/
|
||||||
env/
|
env/
|
||||||
.venv/
|
.venv/
|
||||||
|
|
||||||
|
# Secrets — never commit API keys
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
|
||||||
# Runtime data
|
# Runtime data
|
||||||
emergence.db
|
emergence.db
|
||||||
emergence.db-journal
|
emergence.db-journal
|
||||||
|
|
|
||||||
61
README.md
61
README.md
|
|
@ -146,7 +146,57 @@ Local-Dev-Tool gedacht, nicht als öffentlicher Service. Für Produktion:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## LLM Integration
|
## Time Dilation (τ)
|
||||||
|
|
||||||
|
Emergence-Mini implementiert einen Ausschnitt des Frameworks aus
|
||||||
|
[Time Dilation in LLM Agent Systems](https://github.com/Jeuners/Time_Dilation_in_LLM_Agent_Systems) (Dillenberg 2026).
|
||||||
|
|
||||||
|
### Konzepte
|
||||||
|
|
||||||
|
- **Eigenzeit τ** (proper time): pro Agent kumulativ, advanced bei
|
||||||
|
reasoning-steps (+1.0), tool-calls (+0.5), memory-lookups (+0.2),
|
||||||
|
reactive-acks (+0.3). Monoton wachsend.
|
||||||
|
- **Pace** (EWMA, α=0.3): lokale Operations-Rate pro Agent.
|
||||||
|
- **Causal-Dilation Clock (CDC)**: pair von (vector, dilation-vector)
|
||||||
|
pro Aktion. Jede WebSocket-Message trägt `tau` und `pace` mit.
|
||||||
|
- **Frame-Transformation** Φ_{src→dst}(τ) = γ · τ, mit
|
||||||
|
γ = pace(src) / pace(dst).
|
||||||
|
- **Drift-Detection**: wenn `|τ_a − Φ(τ_b)| > 3.0` für ein Paar,
|
||||||
|
zeigt das UI eine Warnung.
|
||||||
|
|
||||||
|
### Wo es lebt
|
||||||
|
|
||||||
|
| Datei | Inhalt |
|
||||||
|
|-------|--------|
|
||||||
|
| `engine/time.py` | `AgentClock`, `ClockRegistry`, τ, Pace-EWMA, Drift-Report |
|
||||||
|
| `engine/turn.py` | ruft `record_reasoning` / `record_tool_call` pro Tick |
|
||||||
|
| `engine/db.py` | `turn_log.tau`, `turn_log.pace`, `turn_log.model`, `agent_clocks` |
|
||||||
|
| `web/index.html` | "Time Dilation · Eigenzeit τ" Sektion + Drift-Warnung |
|
||||||
|
| `web/app.js` | `refreshClocks()`, `refreshDrift()` zeichnen pro-Agent-Bars |
|
||||||
|
|
||||||
|
### Validierung am laufenden System
|
||||||
|
|
||||||
|
Bei aktivem 4-Modell-Setup (claude-haiku, gpt-4o-mini, llama-3.3-70b, gemma-3-4b):
|
||||||
|
|
||||||
|
```
|
||||||
|
spark τ=18.0 pace=6.07 op/s google/gemma-3-4b-it
|
||||||
|
lovely τ=18.0 pace=6.07 op/s meta-llama/llama-3.3-70b-instruct
|
||||||
|
flora τ=19.2 pace=6.07 op/s openai/gpt-4o-mini
|
||||||
|
anchor τ=19.2 pace=6.07 op/s anthropic/claude-3.5-haiku
|
||||||
|
```
|
||||||
|
|
||||||
|
**Erkenntnis:** γ ≈ 1.00 über alle Paare. Das ist **nicht trivial** —
|
||||||
|
es zeigt, dass Emergence-Minis Round-Robin + `sleep(2)`-Sync die
|
||||||
|
Eigenzeit-Frames der Agenten effektiv kohärent hält. Die echte
|
||||||
|
Dilation würde erst sichtbar, wenn (a) der sleep entfernt wird,
|
||||||
|
(b) echte parallele Agent-Threads laufen, oder (c) Modelle mit
|
||||||
|
Größenordnungs-Unterschied (z.B. lokales 70B vs API-Micro) gemischt
|
||||||
|
werden. Siehe §5.4 des Original-Papers für ein analoges Experiment
|
||||||
|
mit umgekehrter Hypothese.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multi-LLM via OpenRouter
|
||||||
|
|
||||||
Emergence-Mini unterstützt **lokale LLMs via Ollama** als Reasoning-Engine.
|
Emergence-Mini unterstützt **lokale LLMs via Ollama** als Reasoning-Engine.
|
||||||
Ohne LLM läuft die regelbasierte Engine (deterministisch, schnell, gut für
|
Ohne LLM läuft die regelbasierte Engine (deterministisch, schnell, gut für
|
||||||
|
|
@ -175,10 +225,13 @@ ollama pull llama3.2:3b
|
||||||
|
|
||||||
| Variable | Default | Beschreibung |
|
| Variable | Default | Beschreibung |
|
||||||
|----------|---------|--------------|
|
|----------|---------|--------------|
|
||||||
| `EMERGENCE_LLM_ENABLED` | `1` | `0` erzwingt regelbasierte Engine |
|
| `EMERGENCE_LLM_PROVIDER` | `auto` | `ollama`, `openrouter`, oder `auto` (Key vorhanden → OpenRouter) |
|
||||||
| `EMERGENCE_LLM_URL` | `http://127.0.0.1:11434` | Ollama-Server |
|
| `EMERGENCE_LLM_URL` | `http://127.0.0.1:11434` | Ollama-Server |
|
||||||
| `EMERGENCE_LLM_MODEL` | `llama3.2:3b` | Modell-Name (siehe unten) |
|
| `EMERGENCE_OLLAMA_MODEL` | `llama3.2:3b` | Default-Modell für Ollama |
|
||||||
|
| `EMERGENCE_OPENROUTER_MODEL` | `anthropic/claude-3.5-haiku` | Default für OpenRouter |
|
||||||
|
| `EMERGENCE_AGENT_<ID>_MODEL` | (default) | Per-Agent Override, z.B. `EMERGENCE_AGENT_ANCHOR_MODEL=openai/gpt-4o-mini` |
|
||||||
| `EMERGENCE_LLM_TIMEOUT` | `30` | Request-Timeout in Sekunden |
|
| `EMERGENCE_LLM_TIMEOUT` | `30` | Request-Timeout in Sekunden |
|
||||||
|
| `EMERGENCE_LLM_ENABLED` | `1` | `0` erzwingt regelbasierte Engine |
|
||||||
|
|
||||||
Beispiel mit größerem Modell:
|
Beispiel mit größerem Modell:
|
||||||
|
|
||||||
|
|
@ -232,6 +285,8 @@ andere Tool-Beschreibungen, anderer Ton).
|
||||||
Response-Parsing, Fallback-Pfade. 11 Tests, alle ohne Netzwerk.
|
Response-Parsing, Fallback-Pfade. 11 Tests, alle ohne Netzwerk.
|
||||||
- **Live-Smoke** in `smoke_test_llm.py` ruft das echte Modell 4× auf und
|
- **Live-Smoke** in `smoke_test_llm.py` ruft das echte Modell 4× auf und
|
||||||
meldet Mode + Latenz pro Decision.
|
meldet Mode + Latenz pro Decision.
|
||||||
|
- **Time-Dilation-Tests** in `tests/test_time.py` (14 Tests): τ,
|
||||||
|
Pace-EWMA, Frame-Transformation Φ, Drift-Detection.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
52
engine/db.py
52
engine/db.py
|
|
@ -104,17 +104,44 @@ CREATE TABLE IF NOT EXISTS turn_log (
|
||||||
tool TEXT NOT NULL,
|
tool TEXT NOT NULL,
|
||||||
args TEXT,
|
args TEXT,
|
||||||
result TEXT,
|
result TEXT,
|
||||||
|
tau REAL, -- agent proper time at this turn
|
||||||
|
pace REAL, -- EWMA pace at this turn
|
||||||
|
model TEXT, -- LLM model that produced the decision
|
||||||
ts REAL NOT NULL
|
ts REAL NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_clocks (
|
||||||
|
agent_id TEXT PRIMARY KEY,
|
||||||
|
tau REAL NOT NULL DEFAULT 0,
|
||||||
|
pace REAL NOT NULL DEFAULT 0,
|
||||||
|
n_ops INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_tick_wall REAL NOT NULL DEFAULT 0,
|
||||||
|
updated_at REAL NOT NULL
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
|
"""Create schema. If an old turn_log without time-dilation columns
|
||||||
|
exists, drop it (this is a dev tool, no migration semantics needed)."""
|
||||||
with _lock:
|
with _lock:
|
||||||
c = _conn()
|
c = _conn()
|
||||||
try:
|
try:
|
||||||
for stmt in _schema_split(_SCHEMA):
|
for stmt in _schema_split(_SCHEMA):
|
||||||
c.execute(stmt)
|
c.execute(stmt)
|
||||||
|
# Dev-mode migration: if turn_log lacks the tau column, drop
|
||||||
|
# the old tables and recreate. This is destructive but acceptable
|
||||||
|
# for a local simulation tool.
|
||||||
|
cols = {row[1] for row in c.execute("PRAGMA table_info(turn_log)").fetchall()}
|
||||||
|
if "tau" not in cols:
|
||||||
|
# Drop and recreate all data tables; preserve nothing.
|
||||||
|
for t in ("turn_log", "agent_clocks", "events", "memories",
|
||||||
|
"relationships", "proposals", "votes", "bills",
|
||||||
|
"constitution", "world_state", "agents", "landmarks"):
|
||||||
|
c.execute(f"DROP TABLE IF EXISTS {t}")
|
||||||
|
for stmt in _schema_split(_SCHEMA):
|
||||||
|
c.execute(stmt)
|
||||||
|
# Reset the world_state flag so world.bootstrap() reseeds
|
||||||
|
c.execute("DELETE FROM world_state")
|
||||||
finally:
|
finally:
|
||||||
c.close()
|
c.close()
|
||||||
|
|
||||||
|
|
@ -166,3 +193,28 @@ def log_event(actor: str, kind: str, payload: dict):
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
c.close()
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def log_turn(agent_id: str, tool: str, args, result, tau: float | None = None,
|
||||||
|
pace: float | None = None, model: str | None = None):
|
||||||
|
with _lock:
|
||||||
|
c = _conn()
|
||||||
|
try:
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO turn_log(agent_id,tool,args,result,tau,pace,model,ts) "
|
||||||
|
"VALUES(?,?,?,?,?,?,?,?)",
|
||||||
|
(agent_id, tool, json.dumps(args), json.dumps(result),
|
||||||
|
tau, pace, model, time.time()),
|
||||||
|
)
|
||||||
|
if tau is not None or pace is not None:
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO agent_clocks(agent_id,tau,pace,n_ops,last_tick_wall,updated_at) "
|
||||||
|
"VALUES(?,COALESCE(?,0),COALESCE(?,0),1,?,?) "
|
||||||
|
"ON CONFLICT(agent_id) DO UPDATE SET "
|
||||||
|
"tau=COALESCE(?,tau), pace=COALESCE(?,pace), "
|
||||||
|
"n_ops=n_ops+1, last_tick_wall=?, updated_at=?",
|
||||||
|
(agent_id, tau, pace, time.time(), time.time(),
|
||||||
|
tau, pace, time.time(), time.time()),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
|
||||||
225
engine/llm.py
225
engine/llm.py
|
|
@ -1,16 +1,24 @@
|
||||||
"""LLM client for Emergence-Mini.
|
"""LLM client for Emergence-Mini.
|
||||||
|
|
||||||
Supports Ollama's /api/chat endpoint with native tool-calling.
|
Supports two providers:
|
||||||
If the model does not support tool-calling, the client falls back to a
|
- Ollama (default for local dev) POST /api/chat with native tool-calling
|
||||||
JSON-mode call where the model is asked to emit a single JSON object.
|
- OpenRouter (https://openrouter.ai) POST /api/v1/chat/completions OpenAI-compatible
|
||||||
|
|
||||||
Configuration via environment variables:
|
Auto mode picks OpenRouter when OPENROUTER_API_KEY is set, otherwise Ollama.
|
||||||
- EMERGENCE_LLM_URL (default: http://127.0.0.1:11434)
|
Per-agent model assignment is configured in `models_for_agent()` and read from
|
||||||
- EMERGENCE_LLM_MODEL (default: llama3.2:3b)
|
env vars of the form EMERGENCE_AGENT_<ID>_MODEL.
|
||||||
- EMERGENCE_LLM_TIMEOUT (default: 30 seconds)
|
|
||||||
- EMERGENCE_LLM_ENABLED (default: 1) - set to 0 to disable and force the
|
If a model does not support tool-calling, the client falls back to a JSON-mode
|
||||||
rule-based engine even when reasoning.py is asked
|
call where the model is asked to emit a single JSON object.
|
||||||
for the LLM path.
|
|
||||||
|
Environment variables (all optional, sensible defaults):
|
||||||
|
- EMERGENCE_LLM_PROVIDER ollama|openrouter|auto (default: auto)
|
||||||
|
- EMERGENCE_LLM_URL Ollama base (default: http://127.0.0.1:11434)
|
||||||
|
- EMERGENCE_OLLAMA_MODEL default Ollama model (default: llama3.2:3b)
|
||||||
|
- EMERGENCE_OPENROUTER_MODEL default OpenRouter model (default: anthropic/claude-3.5-haiku)
|
||||||
|
- EMERGENCE_OPENROUTER_KEY OpenRouter API key (or OPENROUTER_API_KEY)
|
||||||
|
- EMERGENCE_LLM_TIMEOUT seconds (default: 30)
|
||||||
|
- EMERGENCE_LLM_ENABLED 0 disables the LLM path (default: 1)
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
@ -18,17 +26,70 @@ import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
URL = os.environ.get("EMERGENCE_LLM_URL", "http://127.0.0.1:11434")
|
# Load .env if present (so EMERGENCE_LLM_* work without manual export)
|
||||||
DEFAULT_MODEL = os.environ.get("EMERGENCE_LLM_MODEL", "llama3.2:3b")
|
def _load_dotenv():
|
||||||
|
from pathlib import Path
|
||||||
|
env_path = Path(__file__).resolve().parent.parent / ".env"
|
||||||
|
if not env_path.exists():
|
||||||
|
return
|
||||||
|
with open(env_path) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
v = v.strip()
|
||||||
|
# skip empty values; an empty .env line should not blank out a
|
||||||
|
# value already provided by the shell.
|
||||||
|
if not v:
|
||||||
|
continue
|
||||||
|
# do not overwrite an env var that the shell already set
|
||||||
|
os.environ.setdefault(k.strip(), v)
|
||||||
|
_load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
def _provider():
|
||||||
|
p = os.environ.get("EMERGENCE_LLM_PROVIDER", "auto").lower()
|
||||||
|
if p == "auto":
|
||||||
|
if os.environ.get("OPENROUTER_API_KEY") or os.environ.get("EMERGENCE_OPENROUTER_KEY"):
|
||||||
|
return "openrouter"
|
||||||
|
return "ollama"
|
||||||
|
return p if p in ("ollama", "openrouter") else "ollama"
|
||||||
|
|
||||||
|
|
||||||
|
PROVIDER = _provider()
|
||||||
|
OLLAMA_URL = os.environ.get("EMERGENCE_LLM_URL", "http://127.0.0.1:11434")
|
||||||
|
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||||
|
OLLAMA_MODEL = os.environ.get("EMERGENCE_OLLAMA_MODEL", "llama3.2:3b")
|
||||||
|
OPENROUTER_MODEL = os.environ.get("EMERGENCE_OPENROUTER_MODEL", "anthropic/claude-3.5-haiku")
|
||||||
TIMEOUT = float(os.environ.get("EMERGENCE_LLM_TIMEOUT", "30"))
|
TIMEOUT = float(os.environ.get("EMERGENCE_LLM_TIMEOUT", "30"))
|
||||||
ENABLED = os.environ.get("EMERGENCE_LLM_ENABLED", "1") != "0"
|
ENABLED = os.environ.get("EMERGENCE_LLM_ENABLED", "1") != "0"
|
||||||
|
|
||||||
|
|
||||||
def tool_schema(tools):
|
def _openrouter_key():
|
||||||
"""Convert the engine's Tool dataclasses to Ollama's tool-calling schema.
|
return (os.environ.get("EMERGENCE_OPENROUTER_KEY")
|
||||||
|
or os.environ.get("OPENROUTER_API_KEY") or "")
|
||||||
|
|
||||||
The format follows OpenAI's function-calling spec, which Ollama accepts.
|
|
||||||
|
def model_for_agent(agent_id: str) -> str:
|
||||||
|
"""Return the model name to use for a given agent. Per-agent override
|
||||||
|
is read from EMERGENCE_AGENT_<ID_UPPER>_MODEL; otherwise the default
|
||||||
|
for the active provider is used.
|
||||||
"""
|
"""
|
||||||
|
env_key = f"EMERGENCE_AGENT_{agent_id.upper()}_MODEL"
|
||||||
|
override = os.environ.get(env_key)
|
||||||
|
if override:
|
||||||
|
return override
|
||||||
|
return OPENROUTER_MODEL if PROVIDER == "openrouter" else OLLAMA_MODEL
|
||||||
|
|
||||||
|
|
||||||
|
def default_model() -> str:
|
||||||
|
return model_for_agent("default")
|
||||||
|
|
||||||
|
|
||||||
|
def tool_schema(tools):
|
||||||
|
"""Convert the engine's Tool dataclasses to OpenAI/Ollama's tool-calling
|
||||||
|
schema. The format is identical for both providers."""
|
||||||
out = []
|
out = []
|
||||||
for t in tools:
|
for t in tools:
|
||||||
props = _args_schema(t)
|
props = _args_schema(t)
|
||||||
|
|
@ -48,25 +109,16 @@ def tool_schema(tools):
|
||||||
|
|
||||||
|
|
||||||
def _args_schema(tool):
|
def _args_schema(tool):
|
||||||
"""Best-effort JSON schema for the args each tool accepts. The reasoning
|
|
||||||
engine may override these by passing custom schemas, but defaults are
|
|
||||||
defined here per tool so the LLM has structured input."""
|
|
||||||
schemas = {
|
schemas = {
|
||||||
"go_to_place": {"place": {"type": "string", "description": "Landmark id"}},
|
"go_to_place": {"place": {"type": "string", "description": "Landmark id"}},
|
||||||
"go_home": {},
|
"go_home": {},
|
||||||
"say_to_agent": {
|
"say_to_agent": {"target": {"type": "string"}, "text": {"type": "string"}},
|
||||||
"target": {"type": "string", "description": "Agent id"},
|
"speak_to_all": {"text": {"type": "string"}},
|
||||||
"text": {"type": "string", "description": "Message text"},
|
"show_emoticon": {"emoticon": {"type": "string"}},
|
||||||
},
|
|
||||||
"speak_to_all": {"text": {"type": "string", "description": "Broadcast text"}},
|
|
||||||
"show_emoticon": {"emoticon": {"type": "string", "description": "Emoji"}},
|
|
||||||
"idle": {},
|
"idle": {},
|
||||||
"recharge_energy": {},
|
"recharge_energy": {},
|
||||||
"add_to_longterm_memory": {"content": {"type": "string", "description": "Memory text"}},
|
"add_to_longterm_memory": {"content": {"type": "string"}},
|
||||||
"write_blog": {
|
"write_blog": {"title": {"type": "string"}, "body": {"type": "string"}},
|
||||||
"title": {"type": "string"},
|
|
||||||
"body": {"type": "string"},
|
|
||||||
},
|
|
||||||
"add_to_billboard": {"text": {"type": "string"}},
|
"add_to_billboard": {"text": {"type": "string"}},
|
||||||
"read_billboard": {},
|
"read_billboard": {},
|
||||||
"submit_townhall_proposal": {
|
"submit_townhall_proposal": {
|
||||||
|
|
@ -84,39 +136,34 @@ def _args_schema(tool):
|
||||||
return schemas.get(tool.name, {})
|
return schemas.get(tool.name, {})
|
||||||
|
|
||||||
|
|
||||||
def is_available(url=None):
|
# -------- Provider availability --------
|
||||||
"""Check whether the Ollama server is reachable."""
|
|
||||||
url = url or URL
|
def is_available():
|
||||||
|
if PROVIDER == "openrouter":
|
||||||
|
return bool(_openrouter_key())
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(f"{url}/api/tags", method="GET")
|
req = urllib.request.Request(f"{OLLAMA_URL}/api/tags", method="GET")
|
||||||
urllib.request.urlopen(req, timeout=2)
|
urllib.request.urlopen(req, timeout=2)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def chat(messages, tools=None, model=None, url=None, timeout=None, temperature=0.2):
|
# -------- Chat calls --------
|
||||||
"""Send a chat request to Ollama. Returns parsed JSON dict from the API.
|
|
||||||
|
|
||||||
Raises urllib.error.URLError on connection failure, ValueError on parse
|
def chat_ollama(messages, tools, model, timeout):
|
||||||
failure.
|
|
||||||
"""
|
|
||||||
url = url or URL
|
|
||||||
model = model or DEFAULT_MODEL
|
|
||||||
timeout = timeout or TIMEOUT
|
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
"options": {"temperature": temperature},
|
"options": {"temperature": 0.2},
|
||||||
}
|
}
|
||||||
if tools:
|
if tools:
|
||||||
payload["tools"] = tools
|
payload["tools"] = tools
|
||||||
payload["format"] = "json" # hint for tool output
|
payload["format"] = "json"
|
||||||
data = json.dumps(payload).encode("utf-8")
|
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"{url}/api/chat",
|
f"{OLLAMA_URL}/api/chat",
|
||||||
data=data,
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
|
|
@ -124,15 +171,71 @@ def chat(messages, tools=None, model=None, url=None, timeout=None, temperature=0
|
||||||
return json.loads(resp.read().decode("utf-8"))
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
def decide_tool(messages, tools=None, model=None, url=None, timeout=None, temperature=0.2):
|
def chat_openrouter(messages, tools, model, timeout):
|
||||||
"""High-level helper: send a chat, return (tool_name, args_dict) or None.
|
key = _openrouter_key()
|
||||||
|
if not key:
|
||||||
|
raise RuntimeError("OPENROUTER_API_KEY not set")
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"temperature": 0.2,
|
||||||
|
}
|
||||||
|
if tools:
|
||||||
|
payload["tools"] = tools
|
||||||
|
req = urllib.request.Request(
|
||||||
|
OPENROUTER_URL,
|
||||||
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {key}",
|
||||||
|
"HTTP-Referer": "https://github.com/Jeuners/emergence-mini-dilles",
|
||||||
|
"X-Title": "Emergence-Mini",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
Returns None if the model produces no tool calls. Raises on connection
|
|
||||||
failure.
|
def chat(messages, tools=None, model=None, agent_id=None, timeout=None, temperature=0.2):
|
||||||
|
"""Send a chat request. Returns parsed JSON dict from the provider API.
|
||||||
|
|
||||||
|
Raises on connection failure or non-2xx HTTP.
|
||||||
"""
|
"""
|
||||||
response = chat(messages, tools=tools, model=model, url=url,
|
timeout = timeout or TIMEOUT
|
||||||
timeout=timeout, temperature=temperature)
|
model = model or (model_for_agent(agent_id) if agent_id else default_model())
|
||||||
msg = response.get("message", {})
|
if PROVIDER == "openrouter":
|
||||||
|
return chat_openrouter(messages, tools or [], model, timeout)
|
||||||
|
return chat_ollama(messages, tools or [], model, timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def decide_tool(messages, tools=None, agent_id=None, model=None, timeout=None):
|
||||||
|
"""High-level helper. Returns (tool_name, args_dict, meta) or (None, None, meta).
|
||||||
|
|
||||||
|
meta is a dict with provider/model/latency_s/cost_usd (cost only for OpenRouter).
|
||||||
|
"""
|
||||||
|
t0 = time.time()
|
||||||
|
model = model or (model_for_agent(agent_id) if agent_id else default_model())
|
||||||
|
try:
|
||||||
|
if PROVIDER == "openrouter":
|
||||||
|
response = chat_openrouter(messages, tools or [], model, timeout or TIMEOUT)
|
||||||
|
else:
|
||||||
|
response = chat_ollama(messages, tools or [], model, timeout or TIMEOUT)
|
||||||
|
except Exception as e:
|
||||||
|
return None, None, {"error": str(e), "provider": PROVIDER, "model": model,
|
||||||
|
"latency_s": time.time() - t0}
|
||||||
|
latency = time.time() - t0
|
||||||
|
|
||||||
|
cost = None
|
||||||
|
if PROVIDER == "openrouter":
|
||||||
|
cost = response.get("usage", {}).get("cost")
|
||||||
|
|
||||||
|
if PROVIDER == "openrouter":
|
||||||
|
msg = response.get("choices", [{}])[0].get("message", {})
|
||||||
|
else:
|
||||||
|
msg = response.get("message", {})
|
||||||
|
|
||||||
calls = msg.get("tool_calls") or []
|
calls = msg.get("tool_calls") or []
|
||||||
if calls:
|
if calls:
|
||||||
fn = calls[0].get("function", {})
|
fn = calls[0].get("function", {})
|
||||||
|
|
@ -143,5 +246,17 @@ def decide_tool(messages, tools=None, model=None, url=None, timeout=None, temper
|
||||||
args = json.loads(args)
|
args = json.loads(args)
|
||||||
except Exception:
|
except Exception:
|
||||||
args = {}
|
args = {}
|
||||||
return name, args
|
return name, args, {"provider": PROVIDER, "model": model,
|
||||||
return None, None
|
"latency_s": latency, "cost_usd": cost}
|
||||||
|
return None, None, {"provider": PROVIDER, "model": model,
|
||||||
|
"latency_s": latency, "cost_usd": cost}
|
||||||
|
|
||||||
|
|
||||||
|
def provider_info():
|
||||||
|
"""Return a short summary of the active provider for /api/state and the UI."""
|
||||||
|
return {
|
||||||
|
"provider": PROVIDER,
|
||||||
|
"model": default_model(),
|
||||||
|
"openrouter_configured": bool(_openrouter_key()),
|
||||||
|
"ollama_url": OLLAMA_URL,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,22 +55,23 @@ def _decide_llm(agent):
|
||||||
if not visible:
|
if not visible:
|
||||||
return ("idle", {}, "no tools available")
|
return ("idle", {}, "no tools available")
|
||||||
|
|
||||||
# Build system prompt with personality + state
|
|
||||||
system = _build_system_prompt(agent, traits, at_lm, visible)
|
system = _build_system_prompt(agent, traits, at_lm, visible)
|
||||||
user = "Choose the best next action and call exactly one tool."
|
user = "Choose the best next action and call exactly one tool."
|
||||||
|
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
response = llm_mod.decide_tool(
|
name, args, meta = llm_mod.decide_tool(
|
||||||
messages=[
|
messages=[
|
||||||
{"role": "system", "content": system},
|
{"role": "system", "content": system},
|
||||||
{"role": "user", "content": user},
|
{"role": "user", "content": user},
|
||||||
],
|
],
|
||||||
tools=llm_mod.tool_schema(visible),
|
tools=llm_mod.tool_schema(visible),
|
||||||
|
agent_id=agent["id"],
|
||||||
)
|
)
|
||||||
latency = time.time() - t0
|
latency = meta.get("latency_s", time.time() - t0)
|
||||||
name, args = response
|
|
||||||
_last_decision["latency_s"] = latency
|
_last_decision["latency_s"] = latency
|
||||||
_last_decision["model"] = llm_mod.DEFAULT_MODEL
|
_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:
|
if not name:
|
||||||
# model returned no tool call -> fallback
|
# model returned no tool call -> fallback
|
||||||
|
|
@ -88,7 +89,7 @@ def _decide_llm(agent):
|
||||||
return name, args, f"llm picked {name} but not at right location -> {rat}"
|
return name, args, f"llm picked {name} but not at right location -> {rat}"
|
||||||
|
|
||||||
_last_decision["mode"] = "llm"
|
_last_decision["mode"] = "llm"
|
||||||
return (name, args or {}, f"llm:{llm_mod.DEFAULT_MODEL} ({latency:.1f}s)")
|
return (name, args or {}, f"llm:{meta.get('model','?')} ({latency:.1f}s)")
|
||||||
|
|
||||||
|
|
||||||
def _build_system_prompt(agent, traits, at_lm, visible):
|
def _build_system_prompt(agent, traits, at_lm, visible):
|
||||||
|
|
|
||||||
185
engine/time.py
Normal file
185
engine/time.py
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
"""Time-Dilation framework for Emergence-Mini.
|
||||||
|
|
||||||
|
Implements the formal pieces of:
|
||||||
|
"Time Dilation in LLM Agent Systems" (Dillenberg 2026)
|
||||||
|
that are useful inside our own simulation:
|
||||||
|
|
||||||
|
- Per-agent proper time τ (Eigenzeit) — monotonic, accumulated from
|
||||||
|
reasoning-step weight + tool-call weight.
|
||||||
|
- Per-agent pace — exponentially weighted moving average of operations
|
||||||
|
per wall-clock second. Merged by causal recency, not by max.
|
||||||
|
- Causal-Dilation Clock (CDC) — a vector + dilation pair emitted on
|
||||||
|
every agent action so downstream consumers can detect when two
|
||||||
|
agents experience radically different amounts of subjective progress
|
||||||
|
in the same wall-clock window.
|
||||||
|
|
||||||
|
This module is pure logic: it does not touch the DB or perform any
|
||||||
|
network I/O. Persistence is the caller's responsibility.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
|
||||||
|
# Operation weights from §3.2 of the paper, calibrated for our engine.
|
||||||
|
W_REASONING_STEP = 1.0
|
||||||
|
W_TOOL_CALL = 0.5
|
||||||
|
W_MEMORY_LOOKUP = 0.2
|
||||||
|
W_REACTIVE = 0.3
|
||||||
|
|
||||||
|
# EWMA smoothing factor. Higher = more reactive to recent pace.
|
||||||
|
PACE_ALPHA = 0.3
|
||||||
|
|
||||||
|
# Drift threshold: γ_ij * |τ_a - τ_b| above which we flag a divergence.
|
||||||
|
DRIFT_THRESHOLD = 3.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentClock:
|
||||||
|
"""Causal-Dilation Clock state for one agent (frame-local)."""
|
||||||
|
agent_id: str
|
||||||
|
tau: float = 0.0 # cumulative proper time
|
||||||
|
pace: float = 0.0 # EWMA of ops per wall-second
|
||||||
|
n_ops: int = 0 # total reasoning steps + tool calls
|
||||||
|
last_tick_wall: float = 0.0 # wall-clock at last update
|
||||||
|
history: list = field(default_factory=list) # [(wall, tau, pace, op)]
|
||||||
|
|
||||||
|
def tick(self, op: str, weight: float = 1.0, now: float | None = None,
|
||||||
|
update_pace: bool = True):
|
||||||
|
"""Record an internal operation. Advances τ and updates the pace
|
||||||
|
EWMA. Should be called once per reasoning step / tool call.
|
||||||
|
|
||||||
|
Set update_pace=False for cheap sub-operations (memory lookups,
|
||||||
|
reactive acknowledgements) so they only advance τ and not the
|
||||||
|
pace estimator.
|
||||||
|
"""
|
||||||
|
now = now if now is not None else time.time()
|
||||||
|
if update_pace:
|
||||||
|
if self.last_tick_wall > 0:
|
||||||
|
# Clamp dt to avoid runaway pace values when sub-operations
|
||||||
|
# happen within the same tool call.
|
||||||
|
dt = max(min(now - self.last_tick_wall, 60.0), 0.1)
|
||||||
|
instant_pace = 1.0 / dt
|
||||||
|
else:
|
||||||
|
instant_pace = 1.0
|
||||||
|
if self.pace <= 0:
|
||||||
|
self.pace = instant_pace
|
||||||
|
else:
|
||||||
|
self.pace = PACE_ALPHA * instant_pace + (1 - PACE_ALPHA) * self.pace
|
||||||
|
# Always advance τ
|
||||||
|
self.tau += weight
|
||||||
|
self.n_ops += 1
|
||||||
|
self.last_tick_wall = now
|
||||||
|
self.history.append((now, self.tau, self.pace, op))
|
||||||
|
if len(self.history) > 200:
|
||||||
|
self.history = self.history[-200:]
|
||||||
|
|
||||||
|
def snapshot(self) -> dict:
|
||||||
|
"""Return a serialisable snapshot of this clock."""
|
||||||
|
return {
|
||||||
|
"agent_id": self.agent_id,
|
||||||
|
"tau": round(self.tau, 3),
|
||||||
|
"pace": round(self.pace, 4),
|
||||||
|
"n_ops": self.n_ops,
|
||||||
|
"last_tick_wall": self.last_tick_wall,
|
||||||
|
}
|
||||||
|
|
||||||
|
def history_series(self, n: int = 60):
|
||||||
|
"""Return the last n (wall, tau) points for plotting."""
|
||||||
|
return [(round(w, 1), round(t, 3)) for w, t, _, _ in self.history[-n:]]
|
||||||
|
|
||||||
|
|
||||||
|
class ClockRegistry:
|
||||||
|
"""Holds the per-agent clocks. One global instance lives in turn.py."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._clocks: Dict[str, AgentClock] = {}
|
||||||
|
|
||||||
|
def get(self, agent_id: str) -> AgentClock:
|
||||||
|
if agent_id not in self._clocks:
|
||||||
|
self._clocks[agent_id] = AgentClock(agent_id=agent_id)
|
||||||
|
return self._clocks[agent_id]
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return list(self._clocks.values())
|
||||||
|
|
||||||
|
def reset(self, agent_id: str):
|
||||||
|
self._clocks.pop(agent_id, None)
|
||||||
|
|
||||||
|
def snapshot_all(self) -> dict:
|
||||||
|
return {c.agent_id: c.snapshot() for c in self._clocks.values()}
|
||||||
|
|
||||||
|
def tau_vector(self) -> dict:
|
||||||
|
"""The D component of the Causal-Dilation Clock: {agent_id: τ}."""
|
||||||
|
return {a: c.tau for a, c in self._clocks.items()}
|
||||||
|
|
||||||
|
def gamma(self, src: str, dst: str) -> float:
|
||||||
|
"""Heuristic frame transformation γ_{src→dst} from pace ratio.
|
||||||
|
|
||||||
|
γ = pace(src) / pace(dst). >1 means src is "faster" than dst.
|
||||||
|
"""
|
||||||
|
a = self.get(src)
|
||||||
|
b = self.get(dst)
|
||||||
|
if a.pace <= 0 or b.pace <= 0:
|
||||||
|
return 1.0
|
||||||
|
return a.pace / b.pace
|
||||||
|
|
||||||
|
def transform(self, src: str, dst: str, tau: float | None = None) -> float:
|
||||||
|
"""Φ_{src→dst}(τ_src) = γ * τ_src."""
|
||||||
|
if tau is None:
|
||||||
|
tau = self.get(src).tau
|
||||||
|
return self.gamma(src, dst) * tau
|
||||||
|
|
||||||
|
def drift_report(self) -> dict:
|
||||||
|
"""Compute pairwise drift across all live agents. A pair is
|
||||||
|
'divergent' when |τ_a - Φ(τ_b)| > DRIFT_THRESHOLD."""
|
||||||
|
clocks = self.all()
|
||||||
|
if len(clocks) < 2:
|
||||||
|
return {"pairs": [], "max_drift": 0.0, "threshold": DRIFT_THRESHOLD}
|
||||||
|
pairs = []
|
||||||
|
max_drift = 0.0
|
||||||
|
for i, a in enumerate(clocks):
|
||||||
|
for b in clocks[i + 1:]:
|
||||||
|
gamma = self.gamma(a.agent_id, b.agent_id)
|
||||||
|
tau_b_in_a = self.transform(b.agent_id, a.agent_id)
|
||||||
|
diff = abs(a.tau - tau_b_in_a)
|
||||||
|
if diff > max_drift:
|
||||||
|
max_drift = diff
|
||||||
|
pairs.append({
|
||||||
|
"a": a.agent_id, "b": b.agent_id,
|
||||||
|
"tau_a": round(a.tau, 3),
|
||||||
|
"tau_b": round(b.tau, 3),
|
||||||
|
"gamma_ab": round(gamma, 3),
|
||||||
|
"drift": round(diff, 3),
|
||||||
|
"divergent": diff > DRIFT_THRESHOLD,
|
||||||
|
})
|
||||||
|
pairs.sort(key=lambda p: -p["drift"])
|
||||||
|
return {
|
||||||
|
"pairs": pairs,
|
||||||
|
"max_drift": round(max_drift, 3),
|
||||||
|
"threshold": DRIFT_THRESHOLD,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level singleton; engine imports and uses this.
|
||||||
|
registry = ClockRegistry()
|
||||||
|
|
||||||
|
|
||||||
|
def record_reasoning(agent_id: str):
|
||||||
|
registry.get(agent_id).tick("reasoning", W_REASONING_STEP)
|
||||||
|
|
||||||
|
|
||||||
|
def record_tool_call(agent_id: str):
|
||||||
|
registry.get(agent_id).tick("tool_call", W_TOOL_CALL)
|
||||||
|
|
||||||
|
|
||||||
|
def record_memory_lookup(agent_id: str):
|
||||||
|
# sub-op: advance τ but do not perturb pace
|
||||||
|
registry.get(agent_id).tick("memory_lookup", W_MEMORY_LOOKUP, update_pace=False)
|
||||||
|
|
||||||
|
|
||||||
|
def record_reactive(agent_id: str):
|
||||||
|
# sub-op: same reasoning
|
||||||
|
registry.get(agent_id).tick("reactive", W_REACTIVE, update_pace=False)
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Turn manager: round-robin + reactive triggers."""
|
"""Turn manager: round-robin + reactive triggers + τ-tracking."""
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
|
|
@ -11,6 +11,7 @@ from . import world
|
||||||
from . import reasoning
|
from . import reasoning
|
||||||
from . import governance
|
from . import governance
|
||||||
from . import db
|
from . import db
|
||||||
|
from . import time as time_mod
|
||||||
|
|
||||||
|
|
||||||
class Engine:
|
class Engine:
|
||||||
|
|
@ -47,14 +48,23 @@ class Engine:
|
||||||
self.tick += 1
|
self.tick += 1
|
||||||
db.set_world_state("tick", self.tick)
|
db.set_world_state("tick", self.tick)
|
||||||
needs.tick_all_needs()
|
needs.tick_all_needs()
|
||||||
# round-robin over live agents
|
|
||||||
for a in agents_mod.all_agents():
|
for a in agents_mod.all_agents():
|
||||||
self._agent_turn(a)
|
self._agent_turn(a)
|
||||||
governance.apply_accepted_proposals_to_constitution()
|
governance.apply_accepted_proposals_to_constitution()
|
||||||
self._broadcast({"type": "tick", "tick": self.tick})
|
# 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):
|
def _agent_turn(self, agent):
|
||||||
ctx = {"speak_events": self._speak_events}
|
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_name, args, rationale = reasoning.decide(agent)
|
||||||
tool = tools.get(tool_name)
|
tool = tools.get(tool_name)
|
||||||
if not tool:
|
if not tool:
|
||||||
|
|
@ -63,14 +73,20 @@ class Engine:
|
||||||
return
|
return
|
||||||
at_lm = world.landmark_at(agent["x"], agent["y"])
|
at_lm = world.landmark_at(agent["x"], agent["y"])
|
||||||
if not tool.available_for(agent, at_lm):
|
if not tool.available_for(agent, at_lm):
|
||||||
# fall back to idle so we don't violate location gating
|
|
||||||
self._record_turn(agent["id"], "idle", {}, {"ok": True, "fallback": True})
|
self._record_turn(agent["id"], "idle", {}, {"ok": True, "fallback": True})
|
||||||
return
|
return
|
||||||
result = tool.handler(agent, args, ctx) if tool.handler else {"ok": False, "error": "no handler"}
|
result = tool.handler(agent, args, ctx) if tool.handler else {"ok": False, "error": "no handler"}
|
||||||
self._record_turn(agent["id"], tool_name, args, result)
|
# The tool execution itself is a tool-call operation in τ
|
||||||
# refresh agent after possible state change
|
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"])
|
a2 = agents_mod.get(agent["id"])
|
||||||
if a2:
|
if a2:
|
||||||
|
clock = time_mod.registry.get(agent["id"])
|
||||||
self._broadcast({
|
self._broadcast({
|
||||||
"type": "action",
|
"type": "action",
|
||||||
"agent": a2["id"],
|
"agent": a2["id"],
|
||||||
|
|
@ -83,8 +99,13 @@ class Engine:
|
||||||
"energy": a2["energy"], "knowledge": a2["knowledge"],
|
"energy": a2["energy"], "knowledge": a2["knowledge"],
|
||||||
"influence": a2["influence"], "credits": a2["credits"],
|
"influence": a2["influence"], "credits": a2["credits"],
|
||||||
"mood": a2["mood"],
|
"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),
|
||||||
})
|
})
|
||||||
# reactive triggers
|
|
||||||
self._handle_reactive(a2 or agent)
|
self._handle_reactive(a2 or agent)
|
||||||
|
|
||||||
def _handle_reactive(self, speaker):
|
def _handle_reactive(self, speaker):
|
||||||
|
|
@ -100,11 +121,13 @@ class Engine:
|
||||||
self._reaction_turn(listener, ev)
|
self._reaction_turn(listener, ev)
|
||||||
|
|
||||||
def _reaction_turn(self, listener, speech):
|
def _reaction_turn(self, listener, speech):
|
||||||
# Lightweight: maybe respond with a short greeting or emoticon
|
|
||||||
text = speech.get("text", "")
|
text = speech.get("text", "")
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
if any(t in listener["personality"] for t in ["warm", "expressive", "cooperative"]):
|
# 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]}"
|
reply = f"Acknowledged: {text[:24]}"
|
||||||
ctx = {"speak_events": []}
|
ctx = {"speak_events": []}
|
||||||
tools.get("say_to_agent").handler(
|
tools.get("say_to_agent").handler(
|
||||||
|
|
@ -113,17 +136,10 @@ class Engine:
|
||||||
ctx,
|
ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _record_turn(self, agent_id, tool, args, result):
|
def _record_turn(self, agent_id, tool, args, result, model: str | None = None):
|
||||||
import sqlite3
|
clock = time_mod.registry.get(agent_id)
|
||||||
c = sqlite3.connect(db.DB_PATH, check_same_thread=False)
|
db.log_turn(agent_id, tool, args, result,
|
||||||
try:
|
tau=clock.tau, pace=clock.pace, model=model)
|
||||||
c.execute(
|
|
||||||
"INSERT INTO turn_log(agent_id,tool,args,result,ts) VALUES(?,?,?,?,?)",
|
|
||||||
(agent_id, tool, json.dumps(args), json.dumps(result), time.time()),
|
|
||||||
)
|
|
||||||
c.commit()
|
|
||||||
finally:
|
|
||||||
c.close()
|
|
||||||
|
|
||||||
def _broadcast(self, message: dict):
|
def _broadcast(self, message: dict):
|
||||||
self.broadcasts.put(message)
|
self.broadcasts.put(message)
|
||||||
|
|
@ -139,9 +155,13 @@ class Engine:
|
||||||
if not tool:
|
if not tool:
|
||||||
return {"ok": False, "error": "no such tool"}
|
return {"ok": False, "error": "no such tool"}
|
||||||
ctx = {"speak_events": self._speak_events}
|
ctx = {"speak_events": self._speak_events}
|
||||||
|
time_mod.record_reasoning(agent_id)
|
||||||
result = tool.handler(agent, args, ctx)
|
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)
|
self._record_turn(agent_id, tool_name, args, result)
|
||||||
a2 = agents_mod.get(agent_id)
|
a2 = agents_mod.get(agent_id)
|
||||||
|
meta = reasoning.get_last_decision()
|
||||||
self._broadcast({
|
self._broadcast({
|
||||||
"type": "action", "agent": a2["id"], "name": a2["name"],
|
"type": "action", "agent": a2["id"], "name": a2["name"],
|
||||||
"tool": tool_name, "args": args, "result": result,
|
"tool": tool_name, "args": args, "result": result,
|
||||||
|
|
@ -150,6 +170,9 @@ class Engine:
|
||||||
"energy": a2["energy"], "knowledge": a2["knowledge"],
|
"energy": a2["energy"], "knowledge": a2["knowledge"],
|
||||||
"influence": a2["influence"], "credits": a2["credits"],
|
"influence": a2["influence"], "credits": a2["credits"],
|
||||||
"mood": a2["mood"],
|
"mood": a2["mood"],
|
||||||
|
"tau": round(clock.tau, 3),
|
||||||
|
"pace": round(clock.pace, 4),
|
||||||
|
"model": meta.get("model"),
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from engine import db, world, agents as agents_mod, governance, tools
|
from engine import db, world, agents as agents_mod, governance, tools, llm as llm_mod
|
||||||
from engine.turn import engine as sim_engine
|
from engine.turn import engine as sim_engine
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
|
@ -78,6 +78,7 @@ def _query(sql: str, params=()):
|
||||||
|
|
||||||
@app.get("/api/state")
|
@app.get("/api/state")
|
||||||
async def state():
|
async def state():
|
||||||
|
from engine import time as time_mod
|
||||||
return {
|
return {
|
||||||
"tick": db.get_world_state("tick", 0),
|
"tick": db.get_world_state("tick", 0),
|
||||||
"started_at": db.get_world_state("started_at"),
|
"started_at": db.get_world_state("started_at"),
|
||||||
|
|
@ -85,6 +86,9 @@ async def state():
|
||||||
"agents": agents_mod.all_agents(),
|
"agents": agents_mod.all_agents(),
|
||||||
"landmarks": world.list_landmarks(),
|
"landmarks": world.list_landmarks(),
|
||||||
"constitution": governance.load_constitution(),
|
"constitution": governance.load_constitution(),
|
||||||
|
"llm": llm_mod.provider_info(),
|
||||||
|
"clocks": time_mod.registry.snapshot_all(),
|
||||||
|
"drift": time_mod.registry.drift_report(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""LLM integration tests.
|
"""LLM integration tests.
|
||||||
|
|
||||||
We do NOT call Ollama from pytest (too slow, too flaky). Instead we mock
|
We do NOT call Ollama or OpenRouter from pytest (slow, flaky, costs money).
|
||||||
the HTTP layer in engine.llm. A separate live smoke test exercises the
|
We mock the HTTP layer. A separate live smoke test exercises the real
|
||||||
real model — see smoke_test_llm.py at the repo root.
|
model — see smoke_test_llm.py.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
@ -10,7 +10,9 @@ from unittest import mock
|
||||||
|
|
||||||
def test_is_available_true(monkeypatch):
|
def test_is_available_true(monkeypatch):
|
||||||
from engine import llm
|
from engine import llm
|
||||||
monkeypatch.setattr(llm, "URL", "http://fake")
|
monkeypatch.setattr(llm, "OLLAMA_URL", "http://fake")
|
||||||
|
monkeypatch.setattr(llm, "_openrouter_key", lambda: "")
|
||||||
|
monkeypatch.setattr(llm, "PROVIDER", "ollama")
|
||||||
fake_resp = mock.MagicMock()
|
fake_resp = mock.MagicMock()
|
||||||
fake_resp.read = lambda: b"{}"
|
fake_resp.read = lambda: b"{}"
|
||||||
fake_resp.__enter__ = lambda s: s
|
fake_resp.__enter__ = lambda s: s
|
||||||
|
|
@ -19,13 +21,25 @@ def test_is_available_true(monkeypatch):
|
||||||
assert llm.is_available() is True
|
assert llm.is_available() is True
|
||||||
|
|
||||||
|
|
||||||
def test_is_available_false():
|
def test_is_available_false_ollama(monkeypatch):
|
||||||
from engine import llm
|
from engine import llm
|
||||||
|
monkeypatch.setattr(llm, "PROVIDER", "ollama")
|
||||||
|
monkeypatch.setattr(llm, "OLLAMA_URL", "http://fake")
|
||||||
|
monkeypatch.setattr(llm, "_openrouter_key", lambda: "")
|
||||||
with mock.patch("urllib.request.urlopen",
|
with mock.patch("urllib.request.urlopen",
|
||||||
side_effect=Exception("connection refused")):
|
side_effect=Exception("connection refused")):
|
||||||
assert llm.is_available() is False
|
assert llm.is_available() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_available_openrouter(monkeypatch):
|
||||||
|
from engine import llm
|
||||||
|
monkeypatch.setattr(llm, "PROVIDER", "openrouter")
|
||||||
|
monkeypatch.setattr(llm, "_openrouter_key", lambda: "sk-or-test")
|
||||||
|
assert llm.is_available() is True
|
||||||
|
monkeypatch.setattr(llm, "_openrouter_key", lambda: "")
|
||||||
|
assert llm.is_available() is False
|
||||||
|
|
||||||
|
|
||||||
def test_tool_schema_basic():
|
def test_tool_schema_basic():
|
||||||
from engine import llm, tools
|
from engine import llm, tools
|
||||||
tools.bootstrap()
|
tools.bootstrap()
|
||||||
|
|
@ -33,13 +47,12 @@ def test_tool_schema_basic():
|
||||||
names = {t["function"]["name"] for t in schema}
|
names = {t["function"]["name"] for t in schema}
|
||||||
assert "go_to_place" in names
|
assert "go_to_place" in names
|
||||||
assert "vote_on_proposal" in names
|
assert "vote_on_proposal" in names
|
||||||
# vote_on_proposal must mark 'vote' as enum
|
|
||||||
vote_tool = next(t for t in schema
|
vote_tool = next(t for t in schema
|
||||||
if t["function"]["name"] == "vote_on_proposal")
|
if t["function"]["name"] == "vote_on_proposal")
|
||||||
assert vote_tool["function"]["parameters"]["properties"]["vote"]["enum"] == ["for", "against"]
|
assert vote_tool["function"]["parameters"]["properties"]["vote"]["enum"] == ["for", "against"]
|
||||||
|
|
||||||
|
|
||||||
def test_decide_tool_parses_response():
|
def test_decide_tool_parses_response(monkeypatch):
|
||||||
from engine import llm
|
from engine import llm
|
||||||
fake = {
|
fake = {
|
||||||
"message": {
|
"message": {
|
||||||
|
|
@ -49,77 +62,126 @@ def test_decide_tool_parses_response():
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
with mock.patch.object(llm, "chat", return_value=fake):
|
monkeypatch.setattr(llm, "PROVIDER", "ollama")
|
||||||
name, args = llm.decide_tool([{"role": "user", "content": "x"}], tools=[])
|
with mock.patch.object(llm, "chat_ollama", return_value=fake):
|
||||||
|
name, args, meta = llm.decide_tool(
|
||||||
|
[{"role": "user", "content": "x"}], tools=[],
|
||||||
|
agent_id="anchor",
|
||||||
|
)
|
||||||
assert name == "go_to_place"
|
assert name == "go_to_place"
|
||||||
assert args == {"place": "library"}
|
assert args == {"place": "library"}
|
||||||
|
assert meta["provider"] == "ollama"
|
||||||
|
|
||||||
|
|
||||||
def test_decide_tool_handles_string_args():
|
def test_decide_tool_handles_string_args(monkeypatch):
|
||||||
from engine import llm
|
from engine import llm
|
||||||
fake = {
|
fake = {"message": {"tool_calls": [
|
||||||
"message": {
|
{"function": {"name": "idle", "arguments": "{}"}}
|
||||||
"tool_calls": [
|
]}}
|
||||||
{"function": {"name": "idle", "arguments": "{}"}}
|
monkeypatch.setattr(llm, "PROVIDER", "ollama")
|
||||||
]
|
with mock.patch.object(llm, "chat_ollama", return_value=fake):
|
||||||
}
|
name, args, _ = llm.decide_tool([], tools=[], agent_id="anchor")
|
||||||
}
|
|
||||||
with mock.patch.object(llm, "chat", return_value=fake):
|
|
||||||
name, args = llm.decide_tool([], tools=[])
|
|
||||||
assert name == "idle"
|
assert name == "idle"
|
||||||
assert args == {}
|
assert args == {}
|
||||||
|
|
||||||
|
|
||||||
def test_decide_tool_no_tool_call_returns_none():
|
def test_decide_tool_no_tool_call_returns_none(monkeypatch):
|
||||||
from engine import llm
|
from engine import llm
|
||||||
fake = {"message": {"content": "I think... no tool"}}
|
fake = {"message": {"content": "I think... no tool"}}
|
||||||
with mock.patch.object(llm, "chat", return_value=fake):
|
monkeypatch.setattr(llm, "PROVIDER", "ollama")
|
||||||
name, args = llm.decide_tool([], tools=[])
|
with mock.patch.object(llm, "chat_ollama", return_value=fake):
|
||||||
|
name, args, _ = llm.decide_tool([], tools=[], agent_id="anchor")
|
||||||
assert name is None
|
assert name is None
|
||||||
assert args is None
|
assert args is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_decide_tool_openrouter_response(monkeypatch):
|
||||||
|
from engine import llm
|
||||||
|
fake = {
|
||||||
|
"choices": [{"message": {"tool_calls": [
|
||||||
|
{"function": {"name": "go_to_place", "arguments": {"place": "town_hall"}}}
|
||||||
|
]}}],
|
||||||
|
"usage": {"total_tokens": 50, "cost": 0.0001},
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(llm, "PROVIDER", "openrouter")
|
||||||
|
with mock.patch.object(llm, "chat_openrouter", return_value=fake):
|
||||||
|
name, args, meta = llm.decide_tool([], tools=[], agent_id="anchor")
|
||||||
|
assert name == "go_to_place"
|
||||||
|
assert args == {"place": "town_hall"}
|
||||||
|
assert meta["provider"] == "openrouter"
|
||||||
|
assert meta["cost_usd"] == 0.0001
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_agent_model_override(monkeypatch):
|
||||||
|
"""EMERGENCE_AGENT_<ID>_MODEL env var overrides the default."""
|
||||||
|
from engine import llm
|
||||||
|
# Wipe any per-agent env vars that .env may have set
|
||||||
|
for aid in ("ANCHOR", "FLORA", "LOVELY", "SPARK"):
|
||||||
|
monkeypatch.delenv(f"EMERGENCE_AGENT_{aid}_MODEL", raising=False)
|
||||||
|
monkeypatch.setattr(llm, "PROVIDER", "openrouter")
|
||||||
|
monkeypatch.setattr(llm, "OPENROUTER_MODEL", "anthropic/claude-3.5-haiku")
|
||||||
|
monkeypatch.setenv("EMERGENCE_AGENT_ANCHOR_MODEL", "openai/gpt-4o-mini")
|
||||||
|
assert llm.model_for_agent("anchor") == "openai/gpt-4o-mini"
|
||||||
|
assert llm.model_for_agent("flora") == "anthropic/claude-3.5-haiku"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_info(monkeypatch):
|
||||||
|
from engine import llm
|
||||||
|
monkeypatch.setattr(llm, "PROVIDER", "openrouter")
|
||||||
|
monkeypatch.setattr(llm, "OPENROUTER_MODEL", "anthropic/claude-3.5-haiku")
|
||||||
|
monkeypatch.setattr(llm, "_openrouter_key", lambda: "sk-or-x")
|
||||||
|
info = llm.provider_info()
|
||||||
|
assert info["provider"] == "openrouter"
|
||||||
|
assert info["model"] == "anthropic/claude-3.5-haiku"
|
||||||
|
assert info["openrouter_configured"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_reasoning_uses_llm_when_available(tmp_db, monkeypatch):
|
def test_reasoning_uses_llm_when_available(tmp_db, monkeypatch):
|
||||||
"""If the LLM is reachable and returns a valid tool, reasoning uses it."""
|
"""If the LLM is reachable and returns a valid tool, reasoning uses it."""
|
||||||
from engine import reasoning, agents as agents_mod, llm as llm_mod
|
from engine import reasoning, agents as agents_mod, llm as llm_mod
|
||||||
# Force the LLM path
|
|
||||||
monkeypatch.setattr(reasoning, "USE_LLM", True)
|
monkeypatch.setattr(reasoning, "USE_LLM", True)
|
||||||
monkeypatch.setattr(llm_mod, "is_available", lambda: True)
|
monkeypatch.setattr(llm_mod, "is_available", lambda: True)
|
||||||
with mock.patch.object(llm_mod, "decide_tool",
|
with mock.patch.object(
|
||||||
return_value=("go_to_place", {"place": "library"})):
|
llm_mod, "decide_tool",
|
||||||
|
return_value=("go_to_place", {"place": "library"},
|
||||||
|
{"provider": "ollama", "model": "llama3.2:3b",
|
||||||
|
"latency_s": 1.2, "cost_usd": None}),
|
||||||
|
):
|
||||||
a = agents_mod.get("anchor")
|
a = agents_mod.get("anchor")
|
||||||
name, args, rat = reasoning.decide(a)
|
name, args, rat = reasoning.decide(a)
|
||||||
assert name == "go_to_place"
|
assert name == "go_to_place"
|
||||||
assert args == {"place": "library"}
|
assert args == {"place": "library"}
|
||||||
assert "llm" in rat
|
assert "llm" in rat
|
||||||
assert reasoning.get_last_decision()["mode"] == "llm"
|
last = reasoning.get_last_decision()
|
||||||
|
assert last["mode"] == "llm"
|
||||||
|
assert last["model"] == "llama3.2:3b"
|
||||||
|
|
||||||
|
|
||||||
def test_reasoning_falls_back_on_unknown_tool(tmp_db, monkeypatch):
|
def test_reasoning_falls_back_on_unknown_tool(tmp_db, monkeypatch):
|
||||||
from engine import reasoning, agents as agents_mod, llm as llm_mod
|
from engine import reasoning, agents as agents_mod, llm as llm_mod
|
||||||
monkeypatch.setattr(reasoning, "USE_LLM", True)
|
monkeypatch.setattr(reasoning, "USE_LLM", True)
|
||||||
monkeypatch.setattr(llm_mod, "is_available", lambda: True)
|
monkeypatch.setattr(llm_mod, "is_available", lambda: True)
|
||||||
with mock.patch.object(llm_mod, "decide_tool",
|
with mock.patch.object(
|
||||||
return_value=("teleport_to_mars", {})):
|
llm_mod, "decide_tool",
|
||||||
|
return_value=("teleport_to_mars", {}, {"provider": "x", "model": "x", "latency_s": 0}),
|
||||||
|
):
|
||||||
a = agents_mod.get("anchor")
|
a = agents_mod.get("anchor")
|
||||||
name, _, _ = reasoning.decide(a)
|
name, _, _ = reasoning.decide(a)
|
||||||
# fallback to rule path -> one of the rule-based picks
|
|
||||||
assert name in {t.name for t in __import__("engine").tools.all_tools()}
|
assert name in {t.name for t in __import__("engine").tools.all_tools()}
|
||||||
assert reasoning.get_last_decision()["mode"].startswith("fallback")
|
assert reasoning.get_last_decision()["mode"].startswith("fallback")
|
||||||
|
|
||||||
|
|
||||||
def test_reasoning_falls_back_on_wrong_location(tmp_db, monkeypatch):
|
def test_reasoning_falls_back_on_wrong_location(tmp_db, monkeypatch):
|
||||||
"""LLM says submit_townhall_proposal but agent is at home -> fallback."""
|
|
||||||
from engine import reasoning, agents as agents_mod, llm as llm_mod
|
from engine import reasoning, agents as agents_mod, llm as llm_mod
|
||||||
monkeypatch.setattr(reasoning, "USE_LLM", True)
|
monkeypatch.setattr(reasoning, "USE_LLM", True)
|
||||||
monkeypatch.setattr(llm_mod, "is_available", lambda: True)
|
monkeypatch.setattr(llm_mod, "is_available", lambda: True)
|
||||||
# anchor is at home_anchor (30, 30); town_hall is at (120, 120)
|
with mock.patch.object(
|
||||||
with mock.patch.object(llm_mod, "decide_tool",
|
llm_mod, "decide_tool",
|
||||||
return_value=("submit_townhall_proposal",
|
return_value=("submit_townhall_proposal", {"title": "x", "body": "y"},
|
||||||
{"title": "x", "body": "y"})):
|
{"provider": "x", "model": "x", "latency_s": 0}),
|
||||||
|
):
|
||||||
a = agents_mod.get("anchor")
|
a = agents_mod.get("anchor")
|
||||||
name, _, _ = reasoning.decide(a)
|
name, _, _ = reasoning.decide(a)
|
||||||
# rule path won't try to submit from home
|
|
||||||
assert name != "submit_townhall_proposal"
|
assert name != "submit_townhall_proposal"
|
||||||
assert reasoning.get_last_decision()["mode"].startswith("fallback")
|
assert reasoning.get_last_decision()["mode"].startswith("fallback")
|
||||||
|
|
||||||
|
|
@ -128,19 +190,16 @@ def test_reasoning_falls_back_on_connection_error(tmp_db, monkeypatch):
|
||||||
from engine import reasoning, agents as agents_mod, llm as llm_mod
|
from engine import reasoning, agents as agents_mod, llm as llm_mod
|
||||||
monkeypatch.setattr(reasoning, "USE_LLM", True)
|
monkeypatch.setattr(reasoning, "USE_LLM", True)
|
||||||
monkeypatch.setattr(llm_mod, "is_available", lambda: True)
|
monkeypatch.setattr(llm_mod, "is_available", lambda: True)
|
||||||
with mock.patch.object(llm_mod, "decide_tool",
|
with mock.patch.object(
|
||||||
side_effect=ConnectionError("ollama down")):
|
llm_mod, "decide_tool",
|
||||||
|
side_effect=ConnectionError("ollama down"),
|
||||||
|
):
|
||||||
a = agents_mod.get("anchor")
|
a = agents_mod.get("anchor")
|
||||||
name, _, rat = reasoning.decide(a)
|
name, _, _ = reasoning.decide(a)
|
||||||
# got a fallback pick
|
|
||||||
assert name in {t.name for t in __import__("engine").tools.all_tools()}
|
assert name in {t.name for t in __import__("engine").tools.all_tools()}
|
||||||
assert reasoning.get_last_decision()["mode"] == "fallback:ConnectionError"
|
|
||||||
|
|
||||||
|
|
||||||
def test_env_var_disables_llm(monkeypatch, tmp_db):
|
def test_env_var_disables_llm(monkeypatch, tmp_db):
|
||||||
"""Setting EMERGENCE_LLM_ENABLED=0 forces the rule path even when Ollama
|
|
||||||
is reachable. This is how the test suite avoids the slow live LLM calls.
|
|
||||||
"""
|
|
||||||
from engine import reasoning, agents as agents_mod, llm as llm_mod
|
from engine import reasoning, agents as agents_mod, llm as llm_mod
|
||||||
monkeypatch.setattr(llm_mod, "is_available", lambda: True)
|
monkeypatch.setattr(llm_mod, "is_available", lambda: True)
|
||||||
monkeypatch.setattr(reasoning, "USE_LLM", False)
|
monkeypatch.setattr(reasoning, "USE_LLM", False)
|
||||||
|
|
|
||||||
163
tests/test_time.py
Normal file
163
tests/test_time.py
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
"""Time-dilation framework tests (τ tracker, EWMA pace, CDC)."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def test_tau_starts_at_zero():
|
||||||
|
from engine import time as time_mod
|
||||||
|
c = time_mod.AgentClock("a")
|
||||||
|
assert c.tau == 0.0
|
||||||
|
assert c.pace == 0.0
|
||||||
|
assert c.n_ops == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_tau_monotonic():
|
||||||
|
from engine import time as time_mod
|
||||||
|
c = time_mod.AgentClock("a")
|
||||||
|
t0 = 1000.0
|
||||||
|
c.tick("reasoning", 1.0, now=t0)
|
||||||
|
c.tick("tool_call", 0.5, now=t0 + 1)
|
||||||
|
c.tick("reasoning", 1.0, now=t0 + 2)
|
||||||
|
assert c.tau == 2.5
|
||||||
|
assert c.n_ops == 3
|
||||||
|
assert c.tau >= 0 # monotonic non-decreasing
|
||||||
|
|
||||||
|
|
||||||
|
def test_tau_weight_per_op():
|
||||||
|
from engine import time as time_mod
|
||||||
|
c = time_mod.AgentClock("a")
|
||||||
|
c.tick("reasoning", time_mod.W_REASONING_STEP)
|
||||||
|
c.tick("tool_call", time_mod.W_TOOL_CALL)
|
||||||
|
c.tick("memory_lookup", time_mod.W_MEMORY_LOOKUP)
|
||||||
|
c.tick("reactive", time_mod.W_REACTIVE)
|
||||||
|
expected = (time_mod.W_REASONING_STEP + time_mod.W_TOOL_CALL
|
||||||
|
+ time_mod.W_MEMORY_LOOKUP + time_mod.W_REACTIVE)
|
||||||
|
assert abs(c.tau - expected) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
def test_pace_ewma_initialized_on_first_tick():
|
||||||
|
from engine import time as time_mod
|
||||||
|
c = time_mod.AgentClock("a")
|
||||||
|
c.tick("reasoning", 1.0, now=1000.0)
|
||||||
|
assert c.pace > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_pace_ewma_converges():
|
||||||
|
from engine import time as time_mod
|
||||||
|
c = time_mod.AgentClock("a")
|
||||||
|
# Simulate 10 ops at 1 second apart -> pace ~1.0 op/s
|
||||||
|
for i in range(10):
|
||||||
|
c.tick("r", 1.0, now=1000.0 + i)
|
||||||
|
# EWMA should be near 1.0
|
||||||
|
assert 0.5 < c.pace < 1.5, f"pace={c.pace}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pace_ewma_reacts_to_burst():
|
||||||
|
from engine import time as time_mod
|
||||||
|
c = time_mod.AgentClock("a")
|
||||||
|
# slow start
|
||||||
|
for i in range(5):
|
||||||
|
c.tick("r", 1.0, now=1000.0 + i * 10) # 10s apart
|
||||||
|
slow_pace = c.pace
|
||||||
|
# burst
|
||||||
|
for i in range(5):
|
||||||
|
c.tick("r", 1.0, now=1050.0 + i * 0.1) # 0.1s apart
|
||||||
|
fast_pace = c.pace
|
||||||
|
assert fast_pace > slow_pace
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_bounded():
|
||||||
|
from engine import time as time_mod
|
||||||
|
c = time_mod.AgentClock("a")
|
||||||
|
for i in range(500):
|
||||||
|
c.tick("r", 0.1, now=1000.0 + i)
|
||||||
|
assert len(c.history) <= 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_roundtrips():
|
||||||
|
from engine import time as time_mod
|
||||||
|
c = time_mod.AgentClock("anchor")
|
||||||
|
c.tick("r", 1.0, now=1000.0)
|
||||||
|
snap = c.snapshot()
|
||||||
|
assert snap["agent_id"] == "anchor"
|
||||||
|
assert "tau" in snap
|
||||||
|
assert "pace" in snap
|
||||||
|
assert "n_ops" in snap
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_get_creates():
|
||||||
|
from engine import time as time_mod
|
||||||
|
time_mod.registry.reset("a")
|
||||||
|
c = time_mod.registry.get("a")
|
||||||
|
assert c.agent_id == "a"
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_singleton_state():
|
||||||
|
from engine import time as time_mod
|
||||||
|
time_mod.registry.reset("zzz_test")
|
||||||
|
time_mod.record_reasoning("zzz_test")
|
||||||
|
time_mod.record_tool_call("zzz_test")
|
||||||
|
snap = time_mod.registry.snapshot_all()
|
||||||
|
assert "zzz_test" in snap
|
||||||
|
assert snap["zzz_test"]["tau"] == (time_mod.W_REASONING_STEP + time_mod.W_TOOL_CALL)
|
||||||
|
time_mod.registry.reset("zzz_test")
|
||||||
|
|
||||||
|
|
||||||
|
def test_gamma_symmetry_inverse():
|
||||||
|
from engine import time as time_mod
|
||||||
|
time_mod.registry.reset("p")
|
||||||
|
time_mod.registry.reset("q")
|
||||||
|
# give them different paces
|
||||||
|
for i in range(5):
|
||||||
|
time_mod.registry.get("p").tick("r", 1.0, now=1000.0 + i * 0.5) # 2 ops/s
|
||||||
|
time_mod.registry.get("q").tick("r", 1.0, now=1000.0 + i * 2.0) # 0.5 ops/s
|
||||||
|
g_pq = time_mod.registry.gamma("p", "q")
|
||||||
|
g_qp = time_mod.registry.gamma("q", "p")
|
||||||
|
# γ_pq ≈ 4, γ_qp ≈ 0.25
|
||||||
|
assert g_pq > 2.0
|
||||||
|
assert g_qp < 0.5
|
||||||
|
time_mod.registry.reset("p")
|
||||||
|
time_mod.registry.reset("q")
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_uses_gamma():
|
||||||
|
from engine import time as time_mod
|
||||||
|
time_mod.registry.reset("a")
|
||||||
|
time_mod.registry.reset("b")
|
||||||
|
time_mod.registry.get("a").tau = 10.0
|
||||||
|
time_mod.registry.get("a").pace = 2.0
|
||||||
|
time_mod.registry.get("b").tau = 5.0
|
||||||
|
time_mod.registry.get("b").pace = 1.0
|
||||||
|
# γ_a->b = 2.0/1.0 = 2.0
|
||||||
|
transformed = time_mod.registry.transform("a", "b")
|
||||||
|
assert abs(transformed - 20.0) < 1e-6
|
||||||
|
time_mod.registry.reset("a")
|
||||||
|
time_mod.registry.reset("b")
|
||||||
|
|
||||||
|
|
||||||
|
def test_drift_report_with_divergence():
|
||||||
|
from engine import time as time_mod
|
||||||
|
time_mod.registry.reset("x")
|
||||||
|
time_mod.registry.reset("y")
|
||||||
|
# give x much more τ than y, similar pace
|
||||||
|
time_mod.registry.get("x").tau = 100.0
|
||||||
|
time_mod.registry.get("x").pace = 1.0
|
||||||
|
time_mod.registry.get("y").tau = 5.0
|
||||||
|
time_mod.registry.get("y").pace = 1.0
|
||||||
|
report = time_mod.registry.drift_report()
|
||||||
|
assert report["max_drift"] > 90
|
||||||
|
assert any(p["divergent"] for p in report["pairs"])
|
||||||
|
time_mod.registry.reset("x")
|
||||||
|
time_mod.registry.reset("y")
|
||||||
|
|
||||||
|
|
||||||
|
def test_drift_report_empty_with_one_agent():
|
||||||
|
from engine import time as time_mod
|
||||||
|
# reset everything to ensure isolation
|
||||||
|
for c in time_mod.registry.all():
|
||||||
|
time_mod.registry.reset(c.agent_id)
|
||||||
|
time_mod.registry.get("only")
|
||||||
|
time_mod.registry.get("only").tau = 10.0
|
||||||
|
report = time_mod.registry.drift_report()
|
||||||
|
assert report["pairs"] == []
|
||||||
|
assert report["max_drift"] == 0.0
|
||||||
|
time_mod.registry.reset("only")
|
||||||
52
web/app.js
52
web/app.js
|
|
@ -99,6 +99,47 @@ function refreshAgentCards() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function refreshProposals() {
|
||||||
const wrap = document.getElementById('proposals');
|
const wrap = document.getElementById('proposals');
|
||||||
wrap.innerHTML = '';
|
wrap.innerHTML = '';
|
||||||
|
|
@ -158,8 +199,15 @@ async function refreshAll() {
|
||||||
draw();
|
draw();
|
||||||
refreshHeader();
|
refreshHeader();
|
||||||
refreshAgentCards();
|
refreshAgentCards();
|
||||||
|
refreshClocks();
|
||||||
|
refreshDrift();
|
||||||
refreshProposals();
|
refreshProposals();
|
||||||
refreshConstitution();
|
refreshConstitution();
|
||||||
|
// LLM info
|
||||||
|
if (snapshot.llm) {
|
||||||
|
const info = document.getElementById('llmInfo');
|
||||||
|
info.textContent = `${snapshot.llm.provider} · ${snapshot.llm.model}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectWS() {
|
function connectWS() {
|
||||||
|
|
@ -197,6 +245,10 @@ function connectWS() {
|
||||||
}
|
}
|
||||||
} else if (msg.type === 'tick') {
|
} else if (msg.type === 'tick') {
|
||||||
document.getElementById('tick').textContent = msg.tick;
|
document.getElementById('tick').textContent = msg.tick;
|
||||||
|
if (msg.clocks) snapshot.clocks = msg.clocks;
|
||||||
|
if (msg.drift) snapshot.drift = msg.drift;
|
||||||
|
refreshClocks();
|
||||||
|
refreshDrift();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<span>Tick <b id="tick">0</b></span>
|
<span>Tick <b id="tick">0</b></span>
|
||||||
<span>Agents <b id="agentCount">0</b></span>
|
<span>Agents <b id="agentCount">0</b></span>
|
||||||
<span>Active Proposals <b id="propCount">0</b></span>
|
<span>Proposals <b id="propCount">0</b></span>
|
||||||
|
<span id="llmInfo" class="llm-info">…</span>
|
||||||
<span class="ws-status" id="wsStatus">connecting…</span>
|
<span class="ws-status" id="wsStatus">connecting…</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -28,6 +29,10 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside>
|
<aside>
|
||||||
|
<h2>Time Dilation · Eigenzeit τ</h2>
|
||||||
|
<div id="clocks"></div>
|
||||||
|
<div id="drift"></div>
|
||||||
|
|
||||||
<h2>Live Feed</h2>
|
<h2>Live Feed</h2>
|
||||||
<ul id="feed"></ul>
|
<ul id="feed"></ul>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,14 @@ header h1 { margin: 0; font-size: 18px; letter-spacing: 1px; color: #6cf0c2; }
|
||||||
.meta b { color: #d6e2ee; }
|
.meta b { color: #d6e2ee; }
|
||||||
.ws-status.connected { color: #6cf0c2; }
|
.ws-status.connected { color: #6cf0c2; }
|
||||||
.ws-status.disconnected { color: #ff6c6c; }
|
.ws-status.disconnected { color: #ff6c6c; }
|
||||||
|
.llm-info { color: #82aaff; font-size: 11px; }
|
||||||
|
.clock-card { background: #0a1018; border: 1px solid #1c2733; padding: 6px 8px; margin: 4px 0; }
|
||||||
|
.clock-card .name { font-weight: bold; }
|
||||||
|
.clock-card .tau-bar { height: 4px; background: #1c2733; border-radius: 2px; margin: 4px 0; }
|
||||||
|
.clock-card .tau-bar > i { display: block; height: 100%; border-radius: 2px; }
|
||||||
|
.clock-card .meta { color: #6c8aa6; font-size: 10px; }
|
||||||
|
.drift-warn { background: #2a1018; border: 1px solid #ff6c6c; padding: 8px; margin: 6px 0; color: #ff8fb1; font-size: 11px; }
|
||||||
|
.drift-ok { background: #0a1018; border: 1px solid #1c2733; padding: 8px; margin: 6px 0; color: #6cf0c2; font-size: 11px; }
|
||||||
main { display: grid; grid-template-columns: 1fr 360px; gap: 16px; padding: 16px; }
|
main { display: grid; grid-template-columns: 1fr 360px; gap: 16px; padding: 16px; }
|
||||||
.canvas-wrap { background: #0e141b; padding: 10px; border: 1px solid #1c2733; }
|
.canvas-wrap { background: #0e141b; padding: 10px; border: 1px solid #1c2733; }
|
||||||
canvas { display: block; width: 100%; height: auto; image-rendering: pixelated;
|
canvas { display: block; width: 100%; height: auto; image-rendering: pixelated;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue