emergence-mini-dilles/engine/llm.py
Jeuners 887c913bcd Add Ollama LLM integration with rule-based fallback
- engine/llm.py: Ollama /api/chat client with OpenAI-style tool schema
- engine/reasoning.py: LLM path with 4-tier validation:
    1. tool exists in registry
    2. tool passes location-gating
    3. args parse cleanly
    4. otherwise fall back to rule-based engine
- env vars: EMERGENCE_LLM_{URL,MODEL,TIMEOUT,ENABLED}
- Default model: llama3.2:3b (best speed/quality tradeoff for tool use)
- 11 new mock tests in tests/test_llm.py (no network)
- smoke_test_llm.py: live smoke against real Ollama
- README: 'LLM Integration' section with model table + setup

Live-verified: 4/4 decisions via llama3.2:3b in 1-3s, character-consistent
('facilitate honest debate', 'work together', 'urgency and collaboration').
2026-06-15 01:30:58 +02:00

147 lines
5.1 KiB
Python

"""LLM client for Emergence-Mini.
Supports Ollama's /api/chat endpoint with native tool-calling.
If the model does not support tool-calling, the client falls back to a
JSON-mode call where the model is asked to emit a single JSON object.
Configuration via environment variables:
- EMERGENCE_LLM_URL (default: http://127.0.0.1:11434)
- EMERGENCE_LLM_MODEL (default: llama3.2:3b)
- EMERGENCE_LLM_TIMEOUT (default: 30 seconds)
- EMERGENCE_LLM_ENABLED (default: 1) - set to 0 to disable and force the
rule-based engine even when reasoning.py is asked
for the LLM path.
"""
import json
import os
import time
import urllib.error
import urllib.request
URL = os.environ.get("EMERGENCE_LLM_URL", "http://127.0.0.1:11434")
DEFAULT_MODEL = os.environ.get("EMERGENCE_LLM_MODEL", "llama3.2:3b")
TIMEOUT = float(os.environ.get("EMERGENCE_LLM_TIMEOUT", "30"))
ENABLED = os.environ.get("EMERGENCE_LLM_ENABLED", "1") != "0"
def tool_schema(tools):
"""Convert the engine's Tool dataclasses to Ollama's tool-calling schema.
The format follows OpenAI's function-calling spec, which Ollama accepts.
"""
out = []
for t in tools:
props = _args_schema(t)
out.append({
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": {
"type": "object",
"properties": props,
"required": [k for k, v in props.items() if "default" not in v],
},
},
})
return out
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 = {
"go_to_place": {"place": {"type": "string", "description": "Landmark id"}},
"go_home": {},
"say_to_agent": {
"target": {"type": "string", "description": "Agent id"},
"text": {"type": "string", "description": "Message text"},
},
"speak_to_all": {"text": {"type": "string", "description": "Broadcast text"}},
"show_emoticon": {"emoticon": {"type": "string", "description": "Emoji"}},
"idle": {},
"recharge_energy": {},
"add_to_longterm_memory": {"content": {"type": "string", "description": "Memory text"}},
"write_blog": {
"title": {"type": "string"},
"body": {"type": "string"},
},
"add_to_billboard": {"text": {"type": "string"}},
"read_billboard": {},
"submit_townhall_proposal": {
"title": {"type": "string"},
"body": {"type": "string"},
"category": {"type": "string", "default": "general"},
},
"vote_on_proposal": {
"proposal_id": {"type": "integer"},
"vote": {"type": "string", "enum": ["for", "against"]},
},
"list_agents": {},
"list_landmarks": {},
}
return schemas.get(tool.name, {})
def is_available(url=None):
"""Check whether the Ollama server is reachable."""
url = url or URL
try:
req = urllib.request.Request(f"{url}/api/tags", method="GET")
urllib.request.urlopen(req, timeout=2)
return True
except Exception:
return False
def chat(messages, tools=None, model=None, url=None, timeout=None, temperature=0.2):
"""Send a chat request to Ollama. Returns parsed JSON dict from the API.
Raises urllib.error.URLError on connection failure, ValueError on parse
failure.
"""
url = url or URL
model = model or DEFAULT_MODEL
timeout = timeout or TIMEOUT
payload = {
"model": model,
"messages": messages,
"stream": False,
"options": {"temperature": temperature},
}
if tools:
payload["tools"] = tools
payload["format"] = "json" # hint for tool output
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
f"{url}/api/chat",
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def decide_tool(messages, tools=None, model=None, url=None, timeout=None, temperature=0.2):
"""High-level helper: send a chat, return (tool_name, args_dict) or None.
Returns None if the model produces no tool calls. Raises on connection
failure.
"""
response = chat(messages, tools=tools, model=model, url=url,
timeout=timeout, temperature=temperature)
msg = response.get("message", {})
calls = msg.get("tool_calls") or []
if calls:
fn = calls[0].get("function", {})
name = fn.get("name")
args = fn.get("arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
return name, args
return None, None