diff --git a/plugins/festinger/db/schema.sql b/plugins/festinger/db/schema.sql index 6140c7b..9e2093e 100644 --- a/plugins/festinger/db/schema.sql +++ b/plugins/festinger/db/schema.sql @@ -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() +); diff --git a/plugins/festinger/festinger/db.py b/plugins/festinger/festinger/db.py index b91df73..3e82a0b 100644 --- a/plugins/festinger/festinger/db.py +++ b/plugins/festinger/festinger/db.py @@ -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") diff --git a/plugins/festinger/festinger/main.py b/plugins/festinger/festinger/main.py index e8d43e7..19d7f34 100644 --- a/plugins/festinger/festinger/main.py +++ b/plugins/festinger/festinger/main.py @@ -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 = """ +
+ Assign a model per agent. Agent0 must send X-Agent-Name: <name> on every request.
+ Takes priority over the global write model.
+
| Agent | Model ID | Provider | Model name | |
|---|---|---|---|---|
| Loading… | ||||