Intelligent Agent Router: LLM-powered natural language routing with suggestion UI
This commit is contained in:
@@ -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))
|
||||
+166
-1
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="router-bar">
|
||||
<input type="text" id="router-input" placeholder="What do you need? Ask the router..." onkeydown="if(event.key==='Enter')askRouter()">
|
||||
<button id="router-btn" onclick="askRouter()">Ask</button>
|
||||
</div>
|
||||
<div class="router-result" id="router-result"></div>
|
||||
<div id="bridge-bar"></div>
|
||||
<div class="section-header">
|
||||
<h2>My Agents</h2>
|
||||
@@ -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=`<div class="rr-action">Error</div><div class="rr-reasoning">${r.reasoning||r.error}</div>`;
|
||||
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=`<div class="rr-actions">
|
||||
<button class="btn-save" onclick="acceptRoute(${r.route_id})">Run It</button>
|
||||
<button class="btn-secondary" onclick="rejectRoute(${r.route_id})">Dismiss</button>
|
||||
</div>`;
|
||||
} else if(action==='info'){
|
||||
actionsHtml=`<div class="rr-actions"><button class="btn-secondary" onclick="dismissRouter()">OK</button></div>`;
|
||||
} else {
|
||||
actionsHtml=`<div class="rr-actions"><button class="btn-secondary" onclick="dismissRouter()">OK</button></div>`;
|
||||
}
|
||||
|
||||
resultDiv.innerHTML=`
|
||||
<div class="rr-action">${actionLabels[action]||action}</div>
|
||||
<div class="rr-agent">${r.agent_name||''}</div>
|
||||
<div class="rr-reasoning">${r.reasoning||''}</div>
|
||||
${actionsHtml}`;
|
||||
resultDiv.style.display='block';
|
||||
}catch(e){
|
||||
resultDiv.innerHTML=`<div class="rr-action">Error</div><div class="rr-reasoning">Connection error: ${e}</div>`;
|
||||
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='<span style="color:var(--green)">Running...</span>';
|
||||
const res=await fetch(API+'/api/router/'+routeId+'/accept',{method:'POST'});
|
||||
if(res.ok){
|
||||
const data=await res.json();
|
||||
resultDiv.querySelector('.rr-actions').innerHTML=`<span style="color:var(--green)">${data.message||'Done'}</span>`;
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user