diff --git a/engine/db.py b/engine/db.py index f99ccdf..a042038 100644 --- a/engine/db.py +++ b/engine/db.py @@ -107,6 +107,7 @@ CREATE TABLE IF NOT EXISTS turn_log ( tau REAL, -- agent proper time at this turn pace REAL, -- EWMA pace at this turn model TEXT, -- LLM model that produced the decision + decision_mode TEXT, -- 'llm', 'rule', or 'fallback:...' ts REAL NOT NULL ); CREATE TABLE IF NOT EXISTS agent_clocks ( @@ -128,19 +129,19 @@ def init_db(): try: for stmt in _schema_split(_SCHEMA): 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. + # Dev-mode migration: if turn_log lacks any of the newer columns + # (tau, pace, model, decision_mode), drop and recreate all data + # tables. 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. + required = {"tau", "pace", "model", "decision_mode"} + missing = required - cols + if missing: 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: c.close() @@ -196,15 +197,16 @@ def log_event(actor: str, kind: str, payload: dict): def log_turn(agent_id: str, tool: str, args, result, tau: float | None = None, - pace: float | None = None, model: str | None = None): + pace: float | None = None, model: str | None = None, + decision_mode: str | None = None): with _lock: c = _conn() try: c.execute( - "INSERT INTO turn_log(agent_id,tool,args,result,tau,pace,model,ts) " - "VALUES(?,?,?,?,?,?,?,?)", + "INSERT INTO turn_log(agent_id,tool,args,result,tau,pace,model,decision_mode,ts) " + "VALUES(?,?,?,?,?,?,?,?,?)", (agent_id, tool, json.dumps(args), json.dumps(result), - tau, pace, model, time.time()), + tau, pace, model, decision_mode, time.time()), ) if tau is not None or pace is not None: c.execute( diff --git a/engine/llm.py b/engine/llm.py index 93e482f..b9643a5 100644 --- a/engine/llm.py +++ b/engine/llm.py @@ -58,7 +58,9 @@ def _provider(): PROVIDER = _provider() -OLLAMA_URL = os.environ.get("EMERGENCE_LLM_URL", "http://127.0.0.1:11434") +OLLAMA_URL = os.environ.get("EMERGENCE_OLLAMA_URL", + os.environ.get("EMERGENCE_LLM_URL", "http://127.0.0.1:11434")) +OLLAMA_FALLBACK_URL = os.environ.get("EMERGENCE_OLLAMA_FALLBACK_URL", OLLAMA_URL) 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") @@ -162,34 +164,56 @@ def _args_schema(tool): def is_available(): if PROVIDER == "openrouter": return bool(_openrouter_key()) - try: - req = urllib.request.Request(f"{OLLAMA_URL}/api/tags", method="GET") - urllib.request.urlopen(req, timeout=2) - return True - except Exception: - return False + # Try primary Ollama, then fallback + for url in (OLLAMA_URL, OLLAMA_FALLBACK_URL): + if not url: + continue + try: + req = urllib.request.Request(f"{url}/api/tags", method="GET") + urllib.request.urlopen(req, timeout=2) + return True + except Exception: + continue + return False # -------- Chat calls -------- def chat_ollama(messages, tools, model, timeout): - payload = { - "model": model, - "messages": messages, - "stream": False, - "options": {"temperature": 0.2}, - } - if tools: - payload["tools"] = tools - payload["format"] = "json" - req = urllib.request.Request( - f"{OLLAMA_URL}/api/chat", - data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(req, timeout=timeout) as resp: - return json.loads(resp.read().decode("utf-8")) + last_err = None + for url in (OLLAMA_URL, OLLAMA_FALLBACK_URL): + if not url or url == OLLAMA_URL and OLLAMA_FALLBACK_URL == OLLAMA_URL: + urls = [url] + else: + urls = [url] + # Try primary, then fallback (if different) + pass + # Try each URL in order + for url in (OLLAMA_URL, OLLAMA_FALLBACK_URL): + if not url: + continue + try: + payload = { + "model": model, + "messages": messages, + "stream": False, + "options": {"temperature": 0.2}, + } + if tools: + payload["tools"] = tools + payload["format"] = "json" + req = urllib.request.Request( + f"{url}/api/chat", + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + except Exception as e: + last_err = e + continue + raise last_err or RuntimeError("no Ollama URL configured") def chat_openrouter(messages, tools, model, timeout): @@ -290,4 +314,5 @@ def provider_info(): "model": default_model(), "openrouter_configured": bool(_openrouter_key()), "ollama_url": OLLAMA_URL, + "ollama_fallback_url": OLLAMA_FALLBACK_URL, } diff --git a/engine/turn.py b/engine/turn.py index 4c8fa42..96d3c64 100644 --- a/engine/turn.py +++ b/engine/turn.py @@ -138,8 +138,10 @@ class Engine: def _record_turn(self, agent_id, tool, args, result, model: str | None = None): clock = time_mod.registry.get(agent_id) + meta = reasoning.get_last_decision() db.log_turn(agent_id, tool, args, result, - tau=clock.tau, pace=clock.pace, model=model) + tau=clock.tau, pace=clock.pace, model=model, + decision_mode=meta.get("mode")) def _broadcast(self, message: dict): self.broadcasts.put(message) diff --git a/server.py b/server.py index 9e70d7e..3bf17c2 100644 --- a/server.py +++ b/server.py @@ -141,6 +141,86 @@ async def blogs(): return out +@app.get("/api/texts") +async def texts(limit: int = 20): + """Return recent texts produced by agents (blogs, billboards, speech, + memories) with the model that produced them. + + Each row has: {agent, model, kind, body, ts, source} + source: 'llm' (tool call from LLM) or 'fallback' (rule-based default) + """ + import sqlite3 + out: list[dict] = [] + # blogs + c = sqlite3.connect(db.DB_PATH, check_same_thread=False) + c.row_factory = sqlite3.Row + try: + for r in c.execute("SELECT * FROM bills ORDER BY id DESC LIMIT ?", (limit,)): + try: + p = json.loads(r["body"]) + except Exception: + p = {"title": "Untitled", "body": str(r["body"])[:500]} + tmodel = c.execute("SELECT model FROM turn_log WHERE agent_id=? AND tool='write_blog' ORDER BY id DESC LIMIT 1", + (r["author"],)).fetchone() + out.append({ + "agent": r["author"], + "model": tmodel["model"] if tmodel else "?", + "kind": "blog", + "body": (p.get("title", "") + " — " + p.get("body", ""))[:600], + "ts": r["ts"], + "source": "llm" if tmodel and tmodel["model"] else "fallback", + }) + # billboard posts + for r in c.execute("SELECT * FROM events WHERE kind='billboard_post' ORDER BY id DESC LIMIT ?", (limit,)): + try: + p = json.loads(r["payload"]) + except Exception: + p = {"text": str(r["payload"])[:200]} + tmodel = c.execute("SELECT model FROM turn_log WHERE agent_id=? AND tool='add_to_billboard' ORDER BY id DESC LIMIT 1", + (r["actor"],)).fetchone() + out.append({ + "agent": r["actor"], + "model": tmodel["model"] if tmodel else "?", + "kind": "billboard", + "body": p.get("text", "")[:400], + "ts": r["ts"], + "source": "llm" if tmodel and tmodel["model"] else "fallback", + }) + # memories + for r in c.execute("SELECT * FROM memories ORDER BY id DESC LIMIT ?", (limit,)): + tmodel = c.execute("SELECT model FROM turn_log WHERE agent_id=? AND tool='add_to_longterm_memory' ORDER BY id DESC LIMIT 1", + (r["agent_id"],)).fetchone() + out.append({ + "agent": r["agent_id"], + "model": tmodel["model"] if tmodel else "?", + "kind": "memory", + "body": str(r["content"])[:400], + "ts": r["ts"], + "source": "llm" if tmodel and tmodel["model"] else "fallback", + }) + # speak_to_all / say_to_agent (from turn_log args) + for r in c.execute("SELECT * FROM turn_log WHERE tool IN ('speak_to_all','say_to_agent') ORDER BY id DESC LIMIT ?", (limit,)): + try: + a = json.loads(r["args"]) + except Exception: + continue + text = a.get("text", "") + if not text: + continue + out.append({ + "agent": r["agent_id"], + "model": r["model"] or "?", + "kind": r["tool"].replace("_", " "), + "body": text[:400], + "ts": r["ts"], + "source": "llm" if r["model"] and "/" in r["model"] else "llm", + }) + finally: + c.close() + out.sort(key=lambda x: -x["ts"]) + return out[:limit] + + @app.post("/api/turn/{agent_id}") async def force_turn(agent_id: str, body: dict): tool_name = body.get("tool") diff --git a/web/app.js b/web/app.js index 87e4a84..644d269 100644 --- a/web/app.js +++ b/web/app.js @@ -289,5 +289,7 @@ document.getElementById('manual').addEventListener('submit', async (e) => { refreshAll(); populateManual(); +refreshTexts(); connectWS(); setInterval(refreshProposals, 5000); +setInterval(refreshTexts, 8000); // periodic refresh of historical texts diff --git a/web/index.html b/web/index.html index df0e270..3fcd922 100644 --- a/web/index.html +++ b/web/index.html @@ -33,6 +33,13 @@
+