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:
@@ -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: <name></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>
|
||||
|
||||
Reference in New Issue
Block a user