Phase 7: Agent Selector — per-agent soul.md + ChromaDB memory filtering

- Agent dropdown UI (chip button + hidden select) in composer header
- Session.agent field persists agent selection across refresh
- soul.md loaded per-agent via ephemeral_system_prompt injection
- ChromaDB memory filtered by agent topic (lotus/, sunflower/, etc.)
- Fixed streaming.py: agent→_ai_agent variable shadowing (lines 1161, 1163)
- New API endpoints: /api/agents/topology, /api/agents/memory/search
- Agent metadata registry with emoji, name, description per Tier-2 agent
This commit is contained in:
Rose
2026-04-20 17:34:58 +02:00
parent 00045314f8
commit c705fad626
14 changed files with 2578 additions and 320 deletions

View File

@@ -55,6 +55,7 @@ from api.helpers import (
)
from api import mc as _mc
from api import agents as _agents
from api import heartbeats as _heartbeats
# ── CSRF: validate Origin/Referer on POST ────────────────────────────────────
import re as _re
@@ -570,6 +571,23 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/projects":
return j(handler, {"projects": load_projects()})
# ── Projects Tab Tasks (NEW) ──────────────────────────────────────────────
from api import projects as _projects
if parsed.path == "/api/projects/tasks":
return j(handler, {"tasks": _projects.get_all_tasks()})
if parsed.path == "/api/projects/stats":
return j(handler, _projects.get_stats())
if parsed.path.startswith("/api/projects/") and parsed.path.endswith("/tasks"):
# GET /api/projects/{id}/tasks
project_id = parsed.path.split("/")[3]
proj = _projects.get_project(project_id)
if not proj:
return j(handler, {"error": "Project not found"}, status=404)
return j(handler, {"tasks": proj.get("tasks", [])})
if parsed.path == "/api/session/export":
return _handle_session_export(handler, parsed)
@@ -786,6 +804,13 @@ def handle_get(handler, parsed) -> bool:
agent_id = parsed.path.split("/")[-1]
return j(handler, _agents.get_agent_inbox(agent_id))
# GET /api/agents/{id}/inbox — alternative inbox route
if parsed.path.startswith("/api/agents/") and "/inbox" in parsed.path and parsed.path.count("/") == 4:
parts = parsed.path.split("/")
if len(parts) == 5 and parts[4] == "inbox":
agent_id = parts[3]
return j(handler, _agents.get_agent_inbox(agent_id))
if parsed.path.startswith("/api/agents/config/"):
agent_id = parsed.path.split("/")[-1]
return j(handler, _agents.get_agent_config(agent_id))
@@ -803,84 +828,6 @@ def handle_get(handler, parsed) -> bool:
agent_id = parsed.path.split("/")[-2]
return j(handler, _agents.get_agent_status(agent_id))
# PUT /api/agents/{id}/soul
if parsed.path.endswith("/soul") and method == "PUT":
agent_id = parsed.path.split("/")[-2]
data = read_body(handler)
return j(handler, _agents.update_agent_soul(agent_id, data.get("content", "")))
# PUT /api/agents/{id}/memory
if parsed.path.endswith("/memory") and method == "PUT":
agent_id = parsed.path.split("/")[-2]
data = read_body(handler)
return j(handler, _agents.update_agent_memory(agent_id, data.get("content", "")))
# POST /api/agents/{id}/message
if parsed.path.endswith("/message") and method == "POST":
agent_id = parsed.path.split("/")[-2]
data = read_body(handler)
return j(handler, _agents.send_agent_message(agent_id, data))
# POST /api/agents/{id}/ack/{msg_id}
if "/ack/" in parsed.path and method == "POST":
parts = parsed.path.split("/")
agent_id = parts[2]
msg_id = parts[4]
return j(handler, _agents.ack_agent_message(agent_id, msg_id))
# POST /api/agents/{id}/enable | /disable
if parsed.path.endswith("/enable") or parsed.path.endswith("/disable"):
if method == "POST":
agent_id = parsed.path.split("/")[-2]
action = parsed.path.split("/")[-1]
return j(handler, _agents.set_agent_enabled(agent_id, action == "enable"))
# GET /api/agents/{id}/inbox (full, with limit query param)
if parsed.path.startswith("/api/agents/") and "/inbox" in parsed.path:
parts = parsed.path.split("/")
if len(parts) == 5 and parts[4] == "inbox":
agent_id = parts[3]
limit = int(parse_qs(parsed.query).get("limit", ["50"])[0])
return j(handler, _agents.get_agent_inbox(agent_id, limit=limit))
# GET /api/agents/{id}/activity
if parsed.path.startswith("/api/agents/") and "/activity" in parsed.path:
parts = parsed.path.split("/")
if len(parts) == 5 and parts[4] == "activity":
agent_id = parts[3]
limit = int(parse_qs(parsed.query).get("limit", ["50"])[0])
return j(handler, _agents.get_agent_activity(agent_id, limit=limit))
# GET /api/agents/{id}/errors
if parsed.path.startswith("/api/agents/") and "/errors" in parsed.path:
parts = parsed.path.split("/")
if len(parts) == 5 and parts[4] == "errors":
agent_id = parts[3]
limit = int(parse_qs(parsed.query).get("limit", ["20"])[0])
return j(handler, _agents.get_agent_errors(agent_id, limit=limit))
# GET /api/agents/{id}/usage
if parsed.path.startswith("/api/agents/") and "/usage" in parsed.path:
parts = parsed.path.split("/")
if len(parts) == 5 and parts[4] == "usage":
agent_id = parts[3]
return j(handler, _agents.get_agent_usage(agent_id))
# GET /api/agents/{id}/chat-history
if parsed.path.startswith("/api/agents/") and "/chat-history" in parsed.path:
parts = parsed.path.split("/")
if len(parts) == 5 and parts[4] == "chat-history":
agent_id = parts[3]
limit = int(parse_qs(parsed.query).get("limit", ["20"])[0])
return j(handler, _agents.get_agent_chat_history(agent_id, limit=limit))
# GET /api/agents/{id}/health
if parsed.path.startswith("/api/agents/") and "/health" in parsed.path:
parts = parsed.path.split("/")
if len(parts) == 5 and parts[4] == "health":
agent_id = parts[3]
return j(handler, _agents.get_agent_health(agent_id))
# GET /api/agents/{id}/tasks
if parsed.path.startswith("/api/agents/") and "/tasks" in parsed.path:
parts = parsed.path.split("/")
@@ -892,21 +839,6 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/agents/message-bus":
return j(handler, _agents.get_message_bus_status())
# POST /api/agents/{id}/bus-message — send message to agent via bus
if parsed.path.startswith("/api/agents/") and "/bus-message" in parsed.path:
parts = parsed.path.split("/")
if len(parts) == 5 and parts[4] == "bus-message":
agent_id = parts[3]
data = read_body(handler)
result = _agents.send_bus_message(
to_agent=agent_id,
from_agent=data.get("from_agent", "rose"),
subject=data.get("subject", ""),
content=data.get("content", ""),
msg_type=data.get("type", "request"),
)
return j(handler, result)
# GET /api/agents/memory/search?q= — search all agents
if parsed.path == "/api/agents/memory/search":
return _handle_memory_search(handler, parsed, agent_id=None)
@@ -920,6 +852,51 @@ def handle_get(handler, parsed) -> bool:
if _mem_match:
return _handle_memory_search(handler, parsed, agent_id=_mem_match)
# GET /api/agents/{id}/activity — agent activity events
if parsed.path.startswith("/api/agents/") and "/activity" in parsed.path:
parts = parsed.path.split("/")
if len(parts) == 5 and parts[4] == "activity":
agent_id = parts[3]
qs = parse_qs(parsed.query)
limit = int(qs.get("limit", ["50"])[0])
return j(handler, _agents.get_agent_activity(agent_id, limit))
# GET /api/agents/{id}/errors — agent error log
if parsed.path.startswith("/api/agents/") and "/errors" in parsed.path:
parts = parsed.path.split("/")
if len(parts) == 5 and parts[4] == "errors":
agent_id = parts[3]
qs = parse_qs(parsed.query)
limit = int(qs.get("limit", ["20"])[0])
return j(handler, _agents.get_agent_errors(agent_id, limit))
# GET /api/agents/{id}/health — agent health status
if parsed.path.startswith("/api/agents/") and "/health" in parsed.path:
parts = parsed.path.split("/")
if len(parts) == 5 and parts[4] == "health":
agent_id = parts[3]
return j(handler, _agents.get_agent_health(agent_id))
# GET /api/agents/{id}/chat-history — agent chat sessions
if parsed.path.startswith("/api/agents/") and "/chat-history" in parsed.path:
parts = parsed.path.split("/")
if len(parts) == 5 and parts[4] == "chat-history":
agent_id = parts[3]
qs = parse_qs(parsed.query)
limit = int(qs.get("limit", ["20"])[0])
return j(handler, _agents.get_agent_chat_history(agent_id, limit))
# GET /api/agents/{id}/usage — agent token usage
if parsed.path.startswith("/api/agents/") and "/usage" in parsed.path:
parts = parsed.path.split("/")
if len(parts) == 5 and parts[4] == "usage":
agent_id = parts[3]
return j(handler, _agents.get_agent_usage(agent_id))
# GET /api/agents/topology — agent network graph
if parsed.path == "/api/agents/topology":
return j(handler, _agents.get_topology())
# ── Profile API (GET) ──
if parsed.path == "/api/profiles":
from api.profiles import list_profiles_api, get_active_profile_name
@@ -983,6 +960,12 @@ def handle_get(handler, parsed) -> bool:
except Exception as e:
return j(handler, {"error": str(e)}, status=500)
# GET /api/heartbeats — list all + status
if parsed.path == "/api/heartbeats" or parsed.path.startswith("/api/heartbeats/"):
result = _heartbeats.handle_get(parsed.path)
if result is not None:
return j(handler, result)
return False # 404
@@ -1003,6 +986,45 @@ def handle_post(handler, parsed) -> bool:
body = read_body(handler)
# ── Projects Tab Tasks (NEW) ──────────────────────────────────────────────
from api import projects as _projects
if parsed.path == "/api/projects/tasks":
task = _projects.create_task(body.get("project_id"), body)
return j(handler, task)
# POST /api/agents/{id}/bus-message — send message to agent via bus
if parsed.path.startswith("/api/agents/") and "/bus-message" in parsed.path:
parts = parsed.path.split("/")
if len(parts) == 5 and parts[4] == "bus-message":
agent_id = parts[3]
result = _agents.send_bus_message(
to_agent=agent_id,
from_agent=body.get("from_agent", "rose"),
subject=body.get("subject", ""),
content=body.get("content", ""),
msg_type=body.get("type", "request"),
)
return j(handler, result)
# POST /api/agents/{id}/message
if parsed.path.endswith("/message"):
agent_id = parsed.path.split("/")[-2]
return j(handler, _agents.send_agent_message(agent_id, body))
# POST /api/agents/{id}/ack/{msg_id}
if "/ack/" in parsed.path:
parts = parsed.path.split("/")
agent_id = parts[2]
msg_id = parts[4]
return j(handler, _agents.ack_agent_message(agent_id, msg_id))
# POST /api/agents/{id}/enable | /disable
if parsed.path.endswith("/enable") or parsed.path.endswith("/disable"):
agent_id = parsed.path.split("/")[-2]
action = parsed.path.split("/")[-1]
return j(handler, _agents.set_agent_enabled(agent_id, action == "enable"))
if parsed.path == "/api/session/new":
try:
workspace = str(resolve_trusted_workspace(body.get("workspace"))) if body.get("workspace") else None
@@ -1714,8 +1736,69 @@ def handle_post(handler, parsed) -> bool:
handler.wfile.write(json.dumps({"ok": True}).encode())
return True
# POST /api/heartbeats — create heartbeat
if parsed.path == "/api/heartbeats" or parsed.path.startswith("/api/heartbeats/"):
result = _heartbeats.handle_post(parsed.path, body)
if result is not None:
status = 200
if isinstance(result, tuple):
result, status = result
return j(handler, result, status=status)
# DELETE /api/heartbeats/{id} — cancel heartbeat
if parsed.path.startswith("/api/heartbeats/"):
result = _heartbeats.handle_delete(parsed.path)
if result is not None:
return j(handler, result)
return False # 404
# ── PUT routes ───────────────────────────────────────────────────────────────
def handle_put(handler, parsed) -> bool:
"""Handle all PUT routes. Returns True if handled, False for 404."""
body = read_body(handler)
# ── Projects Tab Tasks (NEW) ──────────────────────────────────────────────
if parsed.path.startswith("/api/projects/tasks/"):
task_id = parsed.path.split("/")[-1]
from api import projects as _projects
result = _projects.update_task(task_id, body)
if result is None:
return j(handler, {"error": "Task not found"}, status=404)
return j(handler, result)
# PUT /api/agents/{id}/soul
if parsed.path.endswith("/soul"):
agent_id = parsed.path.split("/")[-2]
return j(handler, _agents.update_agent_soul(agent_id, body.get("content", "")))
# PUT /api/agents/{id}/memory
if parsed.path.endswith("/memory"):
agent_id = parsed.path.split("/")[-2]
return j(handler, _agents.update_agent_memory(agent_id, body.get("content", "")))
return False # 404
# ── DELETE routes ─────────────────────────────────────────────────────────────
def handle_delete(handler, parsed) -> bool:
"""Handle all DELETE routes. Returns True if handled, False for 404."""
# ── Projects Tab Tasks (NEW) ──────────────────────────────────────────────
if parsed.path.startswith("/api/projects/tasks/"):
task_id = parsed.path.split("/")[-1]
from api import projects as _projects
_projects.delete_task(task_id)
return j(handler, {"ok": True})
return False # 404
# ── GET route helpers ─────────────────────────────────────────────────────────
# MIME types for static file serving. Hoisted to module scope to avoid
@@ -1854,8 +1937,9 @@ def _handle_list_dir(handler, parsed):
return j(
handler,
{
"entries": list_dir(Path(workspace), qs.get("path", ["."])[0]),
"entries": list_dir(Path(workspace), qs.get("path", ["."])[0], qs.get("search", [""])[0]),
"path": qs.get("path", ["."])[0],
"search": qs.get("search", [""])[0],
},
)
except (FileNotFoundError, ValueError) as e:
@@ -2403,6 +2487,7 @@ def _handle_chat_start(handler, body):
except ValueError as e:
return bad(handler, str(e))
model = body.get("model") or s.model
agent = body.get("agent") or s.agent
# Prevent duplicate runs in the same session while a stream is still active.
# This commonly happens after page refresh/reconnect races and can produce
# duplicated clarify cards for what appears to be a single user request.
@@ -2424,6 +2509,7 @@ def _handle_chat_start(handler, body):
stream_id = uuid.uuid4().hex
s.workspace = workspace
s.model = model
s.agent = agent
s.active_stream_id = stream_id
s.pending_user_message = msg
s.pending_attachments = attachments
@@ -2436,6 +2522,7 @@ def _handle_chat_start(handler, body):
thr = threading.Thread(
target=_run_agent_streaming,
args=(s.session_id, msg, model, workspace, stream_id, attachments),
kwargs={"agent": agent},
daemon=True,
)
thr.start()