Add per-agent model assignments (agent_models table)

Festinger now reads X-Agent-Name from every intercepted request and
resolves the utility LLM model in priority order:
  1. agent_models table  — agent-specific (e.g. gunnar → claude, rind → qwen)
  2. write_model_id config — global default
  3. Request mirror       — same provider/model Agent0 is currently using

New API: GET/PUT/DELETE /agent-models
New admin UI: "Agent models" section with assignment form and table.

Agent0 side: add a custom header X-Agent-Name: <name> in the LLM
provider config per agent container (AGENT_NAME env var can drive this).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 19:24:28 +02:00
parent 7210fe2066
commit 10d9e1e2dd
4 changed files with 271 additions and 30 deletions
+11
View File
@@ -111,3 +111,14 @@ CREATE TABLE IF NOT EXISTS kg_write_log (
CREATE INDEX IF NOT EXISTS kwl_created_idx ON kg_write_log (created_at DESC);
CREATE INDEX IF NOT EXISTS kwl_concept_idx ON kg_write_log (concept_id);
CREATE INDEX IF NOT EXISTS kwl_op_idx ON kg_write_log (op);
-- ---------------------------------------------------------------------------
-- agent_models — per-agent LLM model assignments
-- Maps an agent identity (from X-Agent-Name header) to a specific model.
-- Priority over write_model_id (global default) when agent_name is present.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS agent_models (
agent_name TEXT PRIMARY KEY, -- normalised lowercase, e.g. 'gunnar', 'rind'
model_id INT NOT NULL REFERENCES models(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+10
View File
@@ -60,6 +60,16 @@ async def init_schema(pool: asyncpg.Pool) -> None:
await conn.execute(
"ALTER TABLE soas ADD COLUMN IF NOT EXISTS first_seen_context TEXT NOT NULL DEFAULT ''"
)
# Migration: per-agent model assignments (agent_name → model_id)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS agent_models (
agent_name TEXT PRIMARY KEY,
model_id INT NOT NULL REFERENCES models(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
log.info("schema applied")
+170 -2
View File
@@ -578,6 +578,11 @@ def _extract_request_model_config(
# Saliency + recollection pipeline
# ---------------------------------------------------------------------------
def _agent_name_from_headers(headers: dict) -> str:
"""Extract and normalise the agent identity from X-Agent-Name header."""
return headers.get("x-agent-name", "").strip().lower()
async def process_prompt(body: dict, path: str, pool, cfg: dict, request_headers: dict | None = None) -> dict:
"""
Run the saliency + recollection pipeline over the prompt.
@@ -587,9 +592,11 @@ async def process_prompt(body: dict, path: str, pool, cfg: dict, request_headers
conf_floor = float(await get_config(pool, "recollection_confidence_floor", "0.6"))
recency_days = int(await get_config(pool, "recollection_recency_days", "90"))
hdrs = request_headers or {}
# Derive a ModelConfig from the intercepted request so context discovery can
# mirror Agent0's current model without a separate write_model_id config.
request_model = _extract_request_model_config(path, body, request_headers or {}, cfg)
request_model = _extract_request_model_config(path, body, hdrs, cfg)
agent_name = _agent_name_from_headers(hdrs)
# Extract only the last user message — agent responses and reasoning traces
# are noise for both cue scanning and concept discovery.
@@ -651,7 +658,11 @@ async def process_prompt(body: dict, path: str, pool, cfg: dict, request_headers
# 3. Enqueue for LLM-driven discovery if there are candidates to evaluate.
if novel_candidates and len(user_text) >= 20:
await enqueue_context_discover(user_text, novel_candidates, fallback_model=request_model)
await enqueue_context_discover(
user_text, novel_candidates,
agent_name=agent_name,
fallback_model=request_model,
)
if not salient_for_read:
return body
@@ -964,6 +975,81 @@ async def delete_model(model_id: int, request: Request) -> dict:
return {"status": "ok", "deleted": model_id}
# ---------------------------------------------------------------------------
# /agent-models — per-agent model assignments
# ---------------------------------------------------------------------------
@app.get("/agent-models")
async def list_agent_models(request: Request) -> dict:
pool = request.app.state.pool
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT am.agent_name, am.model_id, am.created_at,
m.provider, m.model_name, m.base_url
FROM agent_models am
JOIN models m ON m.id = am.model_id
ORDER BY am.agent_name
"""
)
return {"agent_models": [
{
"agent_name": r["agent_name"],
"model_id": r["model_id"],
"provider": r["provider"],
"model_name": r["model_name"],
"base_url": r["base_url"] or "",
"created_at": r["created_at"].isoformat(),
}
for r in rows
]}
@app.put("/agent-models/{agent_name}")
async def set_agent_model(agent_name: str, request: Request) -> dict:
"""
Assign a model to an agent. agent_name is normalised to lowercase.
Body: {"model_id": 3}
"""
pool = request.app.state.pool
data = await request.json()
model_id = data.get("model_id")
if not model_id:
return {"error": "model_id is required"}
name = agent_name.strip().lower()
if not name:
return {"error": "agent_name must not be empty"}
async with pool.acquire() as conn:
# Verify the model exists
row = await conn.fetchrow("SELECT id, provider, model_name FROM models WHERE id=$1", int(model_id))
if not row:
return {"error": f"model {model_id} not found"}
await conn.execute(
"""
INSERT INTO agent_models (agent_name, model_id)
VALUES ($1, $2)
ON CONFLICT (agent_name) DO UPDATE SET model_id = EXCLUDED.model_id, created_at = now()
""",
name, int(model_id),
)
log.info("agent_model set agent=%s model_id=%s provider=%s model=%s",
name, model_id, row["provider"], row["model_name"])
return {"status": "ok", "agent_name": name, "model_id": int(model_id)}
@app.delete("/agent-models/{agent_name}")
async def delete_agent_model(agent_name: str, request: Request) -> dict:
pool = request.app.state.pool
name = agent_name.strip().lower()
async with pool.acquire() as conn:
result = await conn.execute("DELETE FROM agent_models WHERE agent_name=$1", name)
deleted = int(result.split()[-1]) if result else 0
if not deleted:
return {"error": f"no assignment found for agent '{name}'"}
log.info("agent_model deleted agent=%s", name)
return {"status": "ok", "deleted": name}
@app.get("/models/discover")
async def discover_models(base_url: str = "http://host.docker.internal:1234") -> dict:
"""
@@ -1935,6 +2021,31 @@ ADMIN_HTML = """<!DOCTYPE html>
</details>
</div>
<h2>Agent models</h2>
<p style="font-size:0.83em;color:#666;margin-bottom:0.8em">
Assign a model per agent. Agent0 must send <code>X-Agent-Name: &lt;name&gt;</code> on every request.
Takes priority over the global write model.
</p>
<table id="agent-models-table" style="margin-bottom:0.8em">
<thead><tr><th>Agent</th><th>Model ID</th><th>Provider</th><th>Model name</th><th></th></tr></thead>
<tbody id="agent-models-tbody"><tr><td colspan="5">Loading…</td></tr></tbody>
</table>
<details style="margin-bottom:1em">
<summary style="cursor:pointer;font-size:0.9em;color:#555">Assign model to agent…</summary>
<div style="margin-top:0.6em;display:flex;gap:0.7em;flex-wrap:wrap;align-items:flex-end">
<label style="font-size:0.85em">Agent name (e.g. gunnar)
<input id="am-agent" type="text" placeholder="gunnar"
style="font-family:monospace;padding:5px 8px;border:1px solid #ccc;border-radius:3px;display:block;margin-top:2px;width:140px">
</label>
<label style="font-size:0.85em">Model
<select id="am-model" style="font-family:monospace;padding:5px 8px;border:1px solid #ccc;border-radius:3px;display:block;margin-top:2px">
<option value="">— select —</option>
</select>
</label>
<button onclick="assignAgentModel(this)" style="height:32px">Assign</button>
</div>
</details>
<h2>Actions</h2>
<div class="actions">
<button class="primary" onclick="runResolution(this)">Run conflict resolution now</button>
@@ -2011,6 +2122,11 @@ ADMIN_HTML = """<!DOCTYPE html>
<td><button onclick="setConfig('write_model_id','${{m.id}}')" style="padding:2px 8px;font-size:0.8em;${{writeId==String(m.id)?'background:#2a7a2a;color:#fff;border-color:#2a7a2a':''}}">${{writeId==String(m.id)?'✓ active':'set'}}</button></td>
<td><button onclick="deleteModel(${{m.id}},this)" style="padding:2px 8px;font-size:0.8em;color:#b00;border-color:#b00">✕</button></td>
</tr>`).join('');
// Populate model dropdown in agent-models assignment form
const sel = document.getElementById('am-model');
sel.innerHTML = '<option value="">— select —</option>' +
md.models.map(m => `<option value="${{m.id}}">${{m.id}} — ${{m.provider}} / ${{m.model_name}}</option>`).join('');
}}
async function addModel(btn) {{
@@ -2052,6 +2168,57 @@ ADMIN_HTML = """<!DOCTYPE html>
await loadModels();
}}
async function loadAgentModels() {{
const r = await fetch('/agent-models');
const d = await r.json();
const tbody = document.getElementById('agent-models-tbody');
if (!d.agent_models.length) {{
tbody.innerHTML = '<tr><td colspan="5" style="color:#999">No assignments yet.</td></tr>';
return;
}}
tbody.innerHTML = d.agent_models.map(a => `
<tr>
<td><strong>${{a.agent_name}}</strong></td>
<td>${{a.model_id}}</td>
<td>${{a.provider}}</td>
<td>${{a.model_name}}${{a.base_url ? ' <span style="color:#999;font-size:0.85em">(' + a.base_url + ')</span>' : ''}}</td>
<td><button onclick="removeAgentModel('${{a.agent_name}}',this)" style="padding:2px 8px;font-size:0.8em;color:#b00;border-color:#b00">✕</button></td>
</tr>`).join('');
}}
async function assignAgentModel(btn) {{
const agent = document.getElementById('am-agent').value.trim().toLowerCase();
const model_id = document.getElementById('am-model').value;
if (!agent) {{ alert('Enter an agent name.'); return; }}
if (!model_id) {{ alert('Select a model.'); return; }}
btn.disabled = true;
try {{
const r = await fetch('/agent-models/' + encodeURIComponent(agent), {{
method: 'PUT',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{model_id: parseInt(model_id)}})
}});
const d = await r.json();
if (d.error) {{ showResult('Error: ' + d.error, false); return; }}
showResult('Assigned model ' + model_id + ' to agent "' + agent + '".', true);
document.getElementById('am-agent').value = '';
await loadAgentModels();
}} catch(e) {{ showResult('Error: ' + e.message, false); }}
finally {{ btn.disabled = false; }}
}}
async function removeAgentModel(agent, btn) {{
if (!confirm('Remove model assignment for "' + agent + '"?')) return;
btn.disabled = true;
try {{
const r = await fetch('/agent-models/' + encodeURIComponent(agent), {{method: 'DELETE'}});
const d = await r.json();
if (d.error) {{ showResult('Error: ' + d.error, false); return; }}
await loadAgentModels();
}} catch(e) {{ showResult('Error: ' + e.message, false); }}
finally {{ btn.disabled = false; }}
}}
async function loadConflicts() {{
const r = await fetch('/conflicts');
const d = await r.json();
@@ -2200,6 +2367,7 @@ ADMIN_HTML = """<!DOCTYPE html>
loadConflicts();
loadLog(0);
loadModels();
loadAgentModels();
</script>
</body>
</html>
+80 -28
View File
@@ -32,15 +32,18 @@ class ContextDiscoverRequest:
User message text plus candidate tokens (words absent from the English
dictionary) submitted for LLM-driven concept discovery.
candidate_tokens are hints for the LLM — it decides which are real domain
concepts vs typos/noise, and extracts relationship triples from the text.
candidate_tokens: hints for the LLM — it decides which are real domain
concepts vs typos/noise, and extracts relationship triples from the text.
fallback_model: if set, used when write_model_id is not configured in the
DB. Allows Festinger to mirror the model Agent0 is currently using without
any extra configuration.
agent_name: normalised agent identity from X-Agent-Name header (lowercase).
Used to look up an agent-specific model in the agent_models table.
fallback_model: last-resort ModelConfig derived from the intercepted request.
Used when neither an agent-specific model nor write_model_id is configured.
"""
user_text: str
candidate_tokens: list[str]
agent_name: str = ""
fallback_model: Optional[ModelConfig] = None
@@ -62,19 +65,23 @@ _llm_semaphore: asyncio.Semaphore | None = None
async def enqueue_context_discover(
user_text: str,
candidate_tokens: list[str],
agent_name: str = "",
fallback_model: Optional[ModelConfig] = None,
) -> None:
"""
Enqueue a user message for LLM-driven concept discovery and relation extraction.
fallback_model: ModelConfig derived from the intercepted request. Used when
write_model_id is not configured — lets Festinger mirror Agent0's model
without any extra setup.
agent_name: normalised agent identity (from X-Agent-Name header).
Festinger looks up an agent-specific model in the agent_models table.
fallback_model: last-resort ModelConfig from the intercepted request.
Used when neither an agent-specific model nor write_model_id is configured.
"""
try:
_queue.put_nowait(ContextDiscoverRequest(
user_text=user_text,
candidate_tokens=candidate_tokens,
agent_name=agent_name,
fallback_model=fallback_model,
))
except asyncio.QueueFull:
@@ -119,7 +126,8 @@ async def _worker(pool: asyncpg.Pool) -> None:
# Slow path: fire off without awaiting so the worker stays free.
asyncio.create_task(
_process_context_discover_guarded(
pool, item.user_text, item.candidate_tokens, item.fallback_model
pool, item.user_text, item.candidate_tokens,
item.agent_name, item.fallback_model,
)
)
except Exception as e:
@@ -132,13 +140,14 @@ async def _process_context_discover_guarded(
pool: asyncpg.Pool,
user_text: str,
candidate_tokens: list[str],
agent_name: str = "",
fallback_model: Optional[ModelConfig] = None,
) -> None:
"""Wrapper that acquires the LLM semaphore before concept discovery."""
assert _llm_semaphore is not None
async with _llm_semaphore:
try:
await _process_context_discover(pool, user_text, candidate_tokens, fallback_model)
await _process_context_discover(pool, user_text, candidate_tokens, agent_name, fallback_model)
except Exception as e:
log.exception("context discover task error: %s", e)
@@ -171,20 +180,74 @@ async def _process_cue(pool: asyncpg.Pool, triple: CueTriple) -> None:
log.info("cue triple collision: %s", collision)
async def _resolve_discover_model(
pool: asyncpg.Pool,
agent_name: str,
fallback_model: Optional[ModelConfig],
) -> Optional[ModelConfig]:
"""
Resolve which LLM to use for context discovery.
Priority:
1. Agent-specific model — agent_models table, keyed by agent_name
2. Global default — write_model_id config key
3. Request mirror — fallback_model (same provider/model Agent0 used)
"""
if agent_name:
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT m.provider, m.model_name, m.api_key, m.base_url
FROM agent_models am
JOIN models m ON m.id = am.model_id
WHERE am.agent_name = $1
""",
agent_name,
)
if row:
log.debug(
"context discover: agent=%s → provider=%s model=%s",
agent_name, row["provider"], row["model_name"],
)
return ModelConfig(
provider=row["provider"],
model_name=row["model_name"],
api_key=row["api_key"],
base_url=row["base_url"] or "",
)
write_model_id = await get_config(pool, "write_model_id")
if write_model_id:
m = await get_model_config(pool, write_model_id)
if m:
return m
log.warning("write_model_id=%s not found in models table", write_model_id)
if fallback_model:
log.debug(
"context discover: mirroring request model provider=%s model=%s",
fallback_model.provider, fallback_model.model_name,
)
return fallback_model
return None
async def _process_context_discover(
pool: asyncpg.Pool,
user_text: str,
candidate_tokens: list[str],
agent_name: str = "",
fallback_model: Optional[ModelConfig] = None,
) -> None:
"""
Ask the LLM to evaluate candidate tokens (dictionary misses) and extract
relationship triples for those it judges to be real domain concepts.
Model selection priority:
1. write_model_id config key → explicit utility model override (DB models table)
2. fallback_model → mirrors the model Agent0 used for this request
3. Neither set → skip (log debug)
Model selection priority (see _resolve_discover_model):
1. Agent-specific model (agent_models table)
2. write_model_id config (global default)
3. Request mirror (fallback_model)
Saliency feedback loop:
- Confirmed concepts (triples inserted) → saliency raised to NOVEL_CONFIRMED_SALIENCY
@@ -192,20 +255,9 @@ async def _process_context_discover(
- Rejected concepts (no triples) → saliency stays low (NOVEL_INITIAL_SALIENCY),
effectively hiding typos and noise from the recollection engine.
"""
write_model_id = await get_config(pool, "write_model_id")
if write_model_id:
model = await get_model_config(pool, write_model_id)
if not model:
log.warning("write_model_id=%s not found in models table", write_model_id)
return
elif fallback_model:
model = fallback_model
log.debug(
"context discover: using request model provider=%s model=%s",
model.provider, model.model_name,
)
else:
log.debug("no write_model_id configured and no request model available — skipping context discover")
model = await _resolve_discover_model(pool, agent_name, fallback_model)
if not model:
log.debug("no model resolved for context discover (agent=%r) — skipping", agent_name)
return
seed_dims = ["type", "membership", "runs-on", "tech", "owned-by", "geography"]