diff --git a/agents/agent_router.py b/agents/agent_router.py new file mode 100644 index 0000000..bb95b39 --- /dev/null +++ b/agents/agent_router.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Agent Router +LLM-powered router that reads a natural language request, examines the agent +catalog and user's instances, and recommends the best agent to handle it. +Supports: run_existing, create_and_run, configure, info, not_possible. +""" + +import json +import sys +from shared import DASHBOARD_API, api_request +from llm_client import complete as llm_complete + +SYSTEM_PROMPT = """You are the Agent Router for the Agent Command Center — a personal automation platform. + +Your job: Given a user's natural language request, decide which agent (or combination of agents) should handle it. + +You have access to: +1. The AGENT CATALOG — available agent types that can be enabled +2. The user's EXISTING INSTANCES — agents they've already enabled and configured + +ACTIONS you can recommend: +- "run_existing" — Run one of the user's existing agent instances. Include the instance_id. +- "create_and_run" — The user doesn't have this agent yet. Enable it from the catalog with suggested config, then run it. Include catalog_id and suggested config. +- "configure" — Modify an existing instance's settings. Include instance_id and the config changes. +- "info" — The user is asking a question, not requesting an action. Answer it directly. +- "not_possible" — No agent can handle this request. Explain what's missing. + +RULES: +- Always prefer running an existing instance over creating a new one +- Be specific about WHY you chose this agent +- For "configure" actions, specify exactly what config fields to change +- For "info" actions, answer the question directly in your reasoning +- If the request is ambiguous, pick the most likely interpretation and explain your reasoning +- Keep reasoning concise — 1-3 sentences + +Respond with ONLY valid JSON (no markdown, no code fences): +{ + "action": "run_existing|create_and_run|configure|info|not_possible", + "instance_id": null or integer, + "catalog_id": null or string, + "instance_name": null or string (for create_and_run), + "config": null or object, + "reasoning": "string explaining the decision" +}""" + + +def build_context(catalog, instances): + """Build the context string for the LLM prompt.""" + ctx = "=== AGENT CATALOG (available agent types) ===\n" + for c in catalog: + ctx += f"\n**{c['name']}** (id: {c['id']}, category: {c['category']})" + ctx += f"\n {c['description']}" + if c.get('requires_llm'): + ctx += "\n [Requires LLM]" + if c.get('is_sub_agent'): + ctx += "\n [Sub-agent — called by other agents]" + ctx += "\n" + + ctx += "\n=== YOUR EXISTING AGENT INSTANCES ===\n" + if not instances: + ctx += "\nNo agents enabled yet.\n" + else: + for i in instances: + config_summary = json.dumps(i.get('config', {}))[:200] + ctx += f"\n**{i['name']}** (instance_id: {i['id']}, type: {i['catalog_id']}, status: {i.get('status', '?')})" + ctx += f"\n Config: {config_summary}" + ctx += "\n" + + return ctx + + +def route(user_id, request_text): + """Route a natural language request to the best agent. + + Args: + user_id: Dashboard user ID + request_text: The user's natural language request + + Returns: + dict with: action, instance_id, catalog_id, config, reasoning + """ + # Fetch catalog and user's instances + catalog = api_request(f"{DASHBOARD_API}/api/catalog/all", retries=1) + instances = api_request(f"{DASHBOARD_API}/api/instances/by-user/{user_id}", retries=1) + + # Build the prompt + context = build_context(catalog, instances) + prompt = f"{context}\n=== USER REQUEST ===\n{request_text}" + + # Call LLM + result = llm_complete(user_id, prompt, system=SYSTEM_PROMPT, max_tokens=500) + response_text = result["text"].strip() + + # Parse JSON response + try: + # Handle potential markdown code fences + if response_text.startswith("```"): + response_text = response_text.split("```")[1] + if response_text.startswith("json"): + response_text = response_text[4:] + recommendation = json.loads(response_text) + except json.JSONDecodeError: + recommendation = { + "action": "info", + "reasoning": response_text, + "instance_id": None, + "catalog_id": None, + "config": None, + } + + recommendation["model"] = result.get("model", "") + recommendation["tokens_in"] = result.get("input_tokens", 0) + recommendation["tokens_out"] = result.get("output_tokens", 0) + + # Resolve agent name for display + if recommendation.get("instance_id"): + for i in instances: + if i["id"] == recommendation["instance_id"]: + recommendation["agent_name"] = i["name"] + break + elif recommendation.get("catalog_id"): + for c in catalog: + if c["id"] == recommendation["catalog_id"]: + recommendation["agent_name"] = c["name"] + break + + if "agent_name" not in recommendation: + recommendation["agent_name"] = recommendation.get("catalog_id") or "Unknown" + + return recommendation + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("--user-id", type=int, required=True) + parser.add_argument("request", nargs="+") + args = parser.parse_args() + + result = route(args.user_id, " ".join(args.request)) + print(json.dumps(result, indent=2)) diff --git a/dashboard/app.py b/dashboard/app.py index d2c545f..b3938e9 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -14,7 +14,7 @@ from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from database import get_db, init_db -from models import User, AgentCatalog, AgentInstance, Run, LLMProvider, Bridge +from models import User, AgentCatalog, AgentInstance, Run, LLMProvider, Bridge, RouteLog app = FastAPI(title="Agent Command Center", version="2026.04.12.01") @@ -724,6 +724,171 @@ def admin_delete_catalog(catalog_id: str, admin: dict = Depends(require_admin), return {"status": "deleted"} +# --- Agent Router --- + +class RouterRequest(BaseModel): + request: str + + +@app.get("/api/catalog/all") +def list_catalog_all(db: Session = Depends(get_db)): + """Internal: full catalog for the router (no auth).""" + entries = db.query(AgentCatalog).all() + return [{ + "id": e.id, "name": e.name, "description": e.description, + "category": e.category, "supports_schedule": e.supports_schedule, + "is_sub_agent": e.is_sub_agent, "requires_llm": e.requires_llm, + } for e in entries] + + +@app.post("/api/router") +def ask_router(data: RouterRequest, user: dict = Depends(require_auth), db: Session = Depends(get_db)): + """Route a natural language request to the best agent.""" + import subprocess + import sys + + # Call the router agent + agent_dir = "/app/agents" + config_path = f"/tmp/router_{user['user_id']}_{secrets.token_hex(4)}.json" + with open(config_path, "w") as f: + json.dump({"user_id": user["user_id"], "request": data.request}, f) + + try: + result = subprocess.run( + ["python3", "-c", + f"import sys, json; sys.path.insert(0, '{agent_dir}'); " + f"d = json.load(open('{config_path}')); " + f"from agent_router import route; " + f"r = route(d['user_id'], d['request']); " + f"print(json.dumps(r))"], + capture_output=True, text=True, timeout=120, cwd=agent_dir, + env={**dict(os.environ), "PYTHONPATH": agent_dir}, + ) + os.remove(config_path) + + if result.returncode != 0: + raise RuntimeError(result.stderr[:500]) + + recommendation = json.loads(result.stdout.strip()) + except Exception as e: + return {"error": str(e), "action": "not_possible", "reasoning": f"Router error: {e}"} + + # Log the route + log = RouteLog( + user_id=user["user_id"], + request_text=data.request, + recommended_agent=recommendation.get("agent_name", ""), + action=recommendation.get("action", ""), + reasoning=recommendation.get("reasoning", ""), + outcome="pending", + metadata_={ + "instance_id": recommendation.get("instance_id"), + "catalog_id": recommendation.get("catalog_id"), + "config": recommendation.get("config"), + "model": recommendation.get("model", ""), + "tokens_in": recommendation.get("tokens_in", 0), + "tokens_out": recommendation.get("tokens_out", 0), + }, + ) + db.add(log) + db.commit() + + recommendation["route_id"] = log.id + return recommendation + + +@app.post("/api/router/{route_id}/accept") +def accept_route(route_id: int, user: dict = Depends(require_auth), db: Session = Depends(get_db)): + """Accept a router suggestion and execute it.""" + log = db.query(RouteLog).filter(RouteLog.id == route_id, RouteLog.user_id == user["user_id"]).first() + if not log: + raise HTTPException(status_code=404) + + log.outcome = "accepted" + meta = log.metadata_ or {} + action = log.action + instance_id = meta.get("instance_id") + + # Execute the recommended action + if action == "run_existing" and instance_id: + # Trigger the instance + import subprocess + inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first() + if inst: + agent_dir = "/app/agents" + catalog_id = inst.catalog_id + u = db.query(User).filter(User.id == user["user_id"]).first() + env = {**dict(os.environ), "PYTHONPATH": agent_dir} + + if catalog_id == "daily-briefing": + script_map = {"eric": "eric_briefing.py", "angela": "angela_briefing.py"} + script = script_map.get(u.username) + if script: + env_key = f"{u.username.upper().replace('.', '_')}_INSTANCE_ID" + env[env_key] = str(instance_id) + subprocess.Popen(["python3", f"{agent_dir}/{script}"], env=env, cwd=agent_dir) + elif catalog_id == "project-monitor": + config_path = f"/tmp/pm_config_{instance_id}.json" + with open(config_path, "w") as f: + json.dump({"config": inst.config or {}, "user_id": user["user_id"], "instance_id": instance_id}, f) + subprocess.Popen( + ["python3", "-c", + f"import sys, json; sys.path.insert(0, '{agent_dir}'); " + f"d = json.load(open('{config_path}')); " + f"from project_monitor import run; " + f"run(d['config'], user_id=d['user_id'], instance_id=d['instance_id'])"], + env=env, cwd=agent_dir, + ) + db.commit() + return {"status": "executing", "message": f"Running {inst.name}"} + + elif action == "configure" and instance_id: + inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first() + if inst and meta.get("config"): + new_config = {**(inst.config or {}), **meta["config"]} + inst.config = new_config + from sqlalchemy.orm.attributes import flag_modified + flag_modified(inst, "config") + if new_config.get("project_name") and inst.catalog_id == "project-monitor": + inst.name = f"Project Monitor - {new_config['project_name']}" + db.commit() + return {"status": "configured", "message": f"Updated {inst.name}"} + + elif action == "create_and_run": + catalog_id = meta.get("catalog_id") + catalog = db.query(AgentCatalog).filter(AgentCatalog.id == catalog_id).first() + if catalog: + config = {**(catalog.default_config or {}), **(meta.get("config") or {})} + inst = AgentInstance( + user_id=user["user_id"], + catalog_id=catalog_id, + name=meta.get("instance_name") or catalog.name, + config=config, + schedule="manual", + ) + db.add(inst) + db.commit() + return {"status": "created", "message": f"Created and ready: {inst.name}", "instance_id": inst.id} + + elif action == "info": + db.commit() + return {"status": "info", "message": log.reasoning} + + db.commit() + return {"status": "accepted"} + + +@app.post("/api/router/{route_id}/reject") +def reject_route(route_id: int, user: dict = Depends(require_auth), db: Session = Depends(get_db)): + """Reject a router suggestion.""" + log = db.query(RouteLog).filter(RouteLog.id == route_id, RouteLog.user_id == user["user_id"]).first() + if not log: + raise HTTPException(status_code=404) + log.outcome = "rejected" + db.commit() + return {"status": "rejected"} + + # --- Bridge Management --- class BridgeRegister(BaseModel): diff --git a/dashboard/models.py b/dashboard/models.py index e3edcd7..8912773 100644 --- a/dashboard/models.py +++ b/dashboard/models.py @@ -85,6 +85,20 @@ class Bridge(Base): user = relationship("User") +class RouteLog(Base): + __tablename__ = "route_log" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + request_text = Column(Text, nullable=False) + recommended_agent = Column(String, default="") + action = Column(String, default="") + reasoning = Column(Text, default="") + outcome = Column(String, default="pending") # pending, accepted, rejected, success, failed + metadata_ = Column("metadata", JSON, default=dict) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + class LLMProvider(Base): __tablename__ = "llm_providers" diff --git a/dashboard/static/index.html b/dashboard/static/index.html index cfb5052..a9d4192 100644 --- a/dashboard/static/index.html +++ b/dashboard/static/index.html @@ -95,6 +95,20 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b .bridge-bar .bridge-dot.offline{background:var(--red)} .bridge-bar .bridge-actions{display:flex;gap:.5rem} +/* Router */ +.router-bar{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1rem 1.25rem;margin-bottom:1.5rem;display:flex;gap:.75rem;align-items:center} +.router-bar input{flex:1;padding:.6rem .85rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:.9rem;outline:none} +.router-bar input:focus{border-color:var(--accent)} +.router-bar input::placeholder{color:var(--text-dim)} +.router-bar button{padding:.6rem 1.25rem;background:var(--accent);color:#fff;border:none;border-radius:6px;font-size:.9rem;cursor:pointer;white-space:nowrap} +.router-bar button:hover{background:var(--accent-hover)} +.router-bar button:disabled{opacity:.5;cursor:not-allowed} +.router-result{background:var(--surface);border:1px solid var(--accent);border-radius:10px;padding:1.25rem;margin-bottom:1.5rem;display:none} +.router-result .rr-action{font-size:.75rem;text-transform:uppercase;color:var(--accent);font-weight:600;letter-spacing:.04em;margin-bottom:.4rem} +.router-result .rr-agent{font-size:1.05rem;font-weight:600;margin-bottom:.5rem} +.router-result .rr-reasoning{color:var(--text-dim);font-size:.85rem;margin-bottom:1rem;line-height:1.5} +.router-result .rr-actions{display:flex;gap:.5rem} + .empty-state{text-align:center;padding:3rem;color:var(--text-dim)} .empty-state h3{margin-bottom:.5rem;color:var(--text)} .time-ago{color:var(--text-dim)} @@ -114,6 +128,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
+
+ + +
+

My Agents

@@ -361,6 +380,78 @@ async function enableAgent(catalogId,name){ else{const err=await res.json();alert(err.detail||'Failed to enable agent')} } +// --- Router --- +async function askRouter(){ + const input=document.getElementById('router-input'); + const btn=document.getElementById('router-btn'); + const resultDiv=document.getElementById('router-result'); + const text=input.value.trim(); + if(!text)return; + + btn.disabled=true;btn.textContent='Thinking...'; + resultDiv.style.display='none'; + + try{ + const res=await fetch(API+'/api/router',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request:text})}); + if(res.status===401){location.href='/login';return} + const r=await res.json(); + + if(r.error){ + resultDiv.innerHTML=`
Error
${r.reasoning||r.error}
`; + resultDiv.style.display='block'; + btn.disabled=false;btn.textContent='Ask'; + return; + } + + const action=r.action||'info'; + const actionLabels={run_existing:'Run Agent',create_and_run:'Create & Run',configure:'Update Config',info:'Info',not_possible:'Not Available'}; + + let actionsHtml=''; + if(action==='run_existing'||action==='create_and_run'||action==='configure'){ + actionsHtml=`
+ + +
`; + } else if(action==='info'){ + actionsHtml=`
`; + } else { + actionsHtml=`
`; + } + + resultDiv.innerHTML=` +
${actionLabels[action]||action}
+
${r.agent_name||''}
+
${r.reasoning||''}
+ ${actionsHtml}`; + resultDiv.style.display='block'; + }catch(e){ + resultDiv.innerHTML=`
Error
Connection error: ${e}
`; + resultDiv.style.display='block'; + } + btn.disabled=false;btn.textContent='Ask'; +} + +async function acceptRoute(routeId){ + const resultDiv=document.getElementById('router-result'); + resultDiv.querySelector('.rr-actions').innerHTML='Running...'; + const res=await fetch(API+'/api/router/'+routeId+'/accept',{method:'POST'}); + if(res.ok){ + const data=await res.json(); + resultDiv.querySelector('.rr-actions').innerHTML=`${data.message||'Done'}`; + setTimeout(()=>{dismissRouter();refresh()},3000); + } +} + +async function rejectRoute(routeId){ + await fetch(API+'/api/router/'+routeId+'/reject',{method:'POST'}); + dismissRouter(); +} + +function dismissRouter(){ + document.getElementById('router-result').style.display='none'; + document.getElementById('router-input').value=''; +} + // --- LLM Settings --- async function showLLMSettings(){ const res=await fetch(API+'/api/me/llm');