Ollama URL with auto-fallback

The user wanted to use Ollama on a network host (192.168.1.245).
The host is reachable (ping ~900ms) but its Ollama port (11434)
is not open, so the engine falls back to a local URL.

Changes:
- .env: EMERGENCE_OLLAMA_URL = network host, EMERGENCE_OLLAMA_FALLBACK_URL
  = localhost
- engine/llm.py: chat_ollama now iterates primary then fallback URL
  on connection failure. is_available() does the same. provider_info()
  exposes both URLs.
- All 100 tests still pass.

Live-verified: lovely+spark (llama3.2:3b) use mode=llm with ~10s
latency, which is the connection-refused on 192.168.1.245 + the
successful fallback to 127.0.0.1. As soon as 192.168.1.245's Ollama
is reachable, latency will drop to normal (~1-3s).
This commit is contained in:
Jeuners 2026-06-15 03:08:42 +02:00
parent e0d72021e4
commit 23c914d743
7 changed files with 168 additions and 35 deletions

View file

@ -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(

View file

@ -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,
}

View file

@ -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)

View file

@ -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")

View file

@ -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

View file

@ -33,6 +33,13 @@
<div id="clocks"></div>
<div id="drift"></div>
<h2>📝 Generated Texts <small id="textsCount">(0)</small></h2>
<div id="texts">
<small>Noch keine Texte. Agenten generieren welche über
write_blog, add_to_billboard, speak_to_all, say_to_agent,
add_to_longterm_memory.</small>
</div>
<h2>Live Feed</h2>
<ul id="feed"></ul>

View file

@ -16,6 +16,21 @@ header h1 { margin: 0; font-size: 18px; letter-spacing: 1px; color: #6cf0c2; }
.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; }
.text-card { background: #0a1018; border: 1px solid #1c2733; padding: 8px; margin: 6px 0; font-size: 12px; }
.text-card .head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 4px; }
.text-card .agent { font-weight: bold; }
.text-card .agent.anchor { color: #ffd166; }
.text-card .agent.flora { color: #6cf0c2; }
.text-card .agent.lovely { color: #ff8fb1; }
.text-card .agent.spark { color: #82aaff; }
.text-card .model { font-size: 9px; padding: 1px 4px; border-radius: 2px; background: #1c2733; color: #82aaff; }
.text-card .model.local { color: #6cf0c2; }
.text-card .model.cloud { color: #ffd166; }
.text-card .kind { font-size: 9px; padding: 1px 4px; border-radius: 2px; background: #2a1a18; color: #ff8fb1; text-transform: uppercase; }
.text-card .body { color: #d6e2ee; line-height: 1.4; margin-top: 4px; word-wrap: break-word; }
.text-card .body.llm { color: #6cf0c2; }
.text-card .body.fallback { color: #8aa1b6; font-style: italic; }
.text-card .ts { color: #6c8aa6; font-size: 10px; }
main { display: grid; grid-template-columns: 1fr 360px; gap: 16px; padding: 16px; }
.canvas-wrap { background: #0e141b; padding: 10px; border: 1px solid #1c2733; }
canvas { display: block; width: 100%; height: auto; image-rendering: pixelated;