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_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_concept_idx ON kg_write_log (concept_id);
CREATE INDEX IF NOT EXISTS kwl_op_idx ON kg_write_log (op); 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( await conn.execute(
"ALTER TABLE soas ADD COLUMN IF NOT EXISTS first_seen_context TEXT NOT NULL DEFAULT ''" "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") log.info("schema applied")
+170 -2
View File
@@ -578,6 +578,11 @@ def _extract_request_model_config(
# Saliency + recollection pipeline # 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: 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. 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")) conf_floor = float(await get_config(pool, "recollection_confidence_floor", "0.6"))
recency_days = int(await get_config(pool, "recollection_recency_days", "90")) 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 # Derive a ModelConfig from the intercepted request so context discovery can
# mirror Agent0's current model without a separate write_model_id config. # 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 # Extract only the last user message — agent responses and reasoning traces
# are noise for both cue scanning and concept discovery. # 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. # 3. Enqueue for LLM-driven discovery if there are candidates to evaluate.
if novel_candidates and len(user_text) >= 20: 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: if not salient_for_read:
return body return body
@@ -964,6 +975,81 @@ async def delete_model(model_id: int, request: Request) -> dict:
return {"status": "ok", "deleted": model_id} 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") @app.get("/models/discover")
async def discover_models(base_url: str = "http://host.docker.internal:1234") -> dict: async def discover_models(base_url: str = "http://host.docker.internal:1234") -> dict:
""" """
@@ -1935,6 +2021,31 @@ ADMIN_HTML = """<!DOCTYPE html>
</details> </details>
</div> </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> <h2>Actions</h2>
<div class="actions"> <div class="actions">
<button class="primary" onclick="runResolution(this)">Run conflict resolution now</button> <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="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> <td><button onclick="deleteModel(${{m.id}},this)" style="padding:2px 8px;font-size:0.8em;color:#b00;border-color:#b00">✕</button></td>
</tr>`).join(''); </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) {{ async function addModel(btn) {{
@@ -2052,6 +2168,57 @@ ADMIN_HTML = """<!DOCTYPE html>
await loadModels(); 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() {{ async function loadConflicts() {{
const r = await fetch('/conflicts'); const r = await fetch('/conflicts');
const d = await r.json(); const d = await r.json();
@@ -2200,6 +2367,7 @@ ADMIN_HTML = """<!DOCTYPE html>
loadConflicts(); loadConflicts();
loadLog(0); loadLog(0);
loadModels(); loadModels();
loadAgentModels();
</script> </script>
</body> </body>
</html> </html>
+80 -28
View File
@@ -32,15 +32,18 @@ class ContextDiscoverRequest:
User message text plus candidate tokens (words absent from the English User message text plus candidate tokens (words absent from the English
dictionary) submitted for LLM-driven concept discovery. dictionary) submitted for LLM-driven concept discovery.
candidate_tokens are hints for the LLM — it decides which are real domain candidate_tokens: hints for the LLM — it decides which are real domain
concepts vs typos/noise, and extracts relationship triples from the text. 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 agent_name: normalised agent identity from X-Agent-Name header (lowercase).
DB. Allows Festinger to mirror the model Agent0 is currently using without Used to look up an agent-specific model in the agent_models table.
any extra configuration.
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 user_text: str
candidate_tokens: list[str] candidate_tokens: list[str]
agent_name: str = ""
fallback_model: Optional[ModelConfig] = None fallback_model: Optional[ModelConfig] = None
@@ -62,19 +65,23 @@ _llm_semaphore: asyncio.Semaphore | None = None
async def enqueue_context_discover( async def enqueue_context_discover(
user_text: str, user_text: str,
candidate_tokens: list[str], candidate_tokens: list[str],
agent_name: str = "",
fallback_model: Optional[ModelConfig] = None, fallback_model: Optional[ModelConfig] = None,
) -> None: ) -> None:
""" """
Enqueue a user message for LLM-driven concept discovery and relation extraction. Enqueue a user message for LLM-driven concept discovery and relation extraction.
fallback_model: ModelConfig derived from the intercepted request. Used when agent_name: normalised agent identity (from X-Agent-Name header).
write_model_id is not configured — lets Festinger mirror Agent0's model Festinger looks up an agent-specific model in the agent_models table.
without any extra setup.
fallback_model: last-resort ModelConfig from the intercepted request.
Used when neither an agent-specific model nor write_model_id is configured.
""" """
try: try:
_queue.put_nowait(ContextDiscoverRequest( _queue.put_nowait(ContextDiscoverRequest(
user_text=user_text, user_text=user_text,
candidate_tokens=candidate_tokens, candidate_tokens=candidate_tokens,
agent_name=agent_name,
fallback_model=fallback_model, fallback_model=fallback_model,
)) ))
except asyncio.QueueFull: 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. # Slow path: fire off without awaiting so the worker stays free.
asyncio.create_task( asyncio.create_task(
_process_context_discover_guarded( _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: except Exception as e:
@@ -132,13 +140,14 @@ async def _process_context_discover_guarded(
pool: asyncpg.Pool, pool: asyncpg.Pool,
user_text: str, user_text: str,
candidate_tokens: list[str], candidate_tokens: list[str],
agent_name: str = "",
fallback_model: Optional[ModelConfig] = None, fallback_model: Optional[ModelConfig] = None,
) -> None: ) -> None:
"""Wrapper that acquires the LLM semaphore before concept discovery.""" """Wrapper that acquires the LLM semaphore before concept discovery."""
assert _llm_semaphore is not None assert _llm_semaphore is not None
async with _llm_semaphore: async with _llm_semaphore:
try: 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: except Exception as e:
log.exception("context discover task error: %s", 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) 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( async def _process_context_discover(
pool: asyncpg.Pool, pool: asyncpg.Pool,
user_text: str, user_text: str,
candidate_tokens: list[str], candidate_tokens: list[str],
agent_name: str = "",
fallback_model: Optional[ModelConfig] = None, fallback_model: Optional[ModelConfig] = None,
) -> None: ) -> None:
""" """
Ask the LLM to evaluate candidate tokens (dictionary misses) and extract Ask the LLM to evaluate candidate tokens (dictionary misses) and extract
relationship triples for those it judges to be real domain concepts. relationship triples for those it judges to be real domain concepts.
Model selection priority: Model selection priority (see _resolve_discover_model):
1. write_model_id config key → explicit utility model override (DB models table) 1. Agent-specific model (agent_models table)
2. fallback_model → mirrors the model Agent0 used for this request 2. write_model_id config (global default)
3. Neither set → skip (log debug) 3. Request mirror (fallback_model)
Saliency feedback loop: Saliency feedback loop:
- Confirmed concepts (triples inserted) → saliency raised to NOVEL_CONFIRMED_SALIENCY - 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), - Rejected concepts (no triples) → saliency stays low (NOVEL_INITIAL_SALIENCY),
effectively hiding typos and noise from the recollection engine. effectively hiding typos and noise from the recollection engine.
""" """
write_model_id = await get_config(pool, "write_model_id") model = await _resolve_discover_model(pool, agent_name, fallback_model)
if write_model_id: if not model:
model = await get_model_config(pool, write_model_id) log.debug("no model resolved for context discover (agent=%r) — skipping", agent_name)
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")
return return
seed_dims = ["type", "membership", "runs-on", "tech", "owned-by", "geography"] seed_dims = ["type", "membership", "runs-on", "tech", "owned-by", "geography"]