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:
@@ -1067,3 +1067,40 @@ def _search_all_agents_memory(query: str, limit: int = 20) -> list:
|
||||
return matches
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ── Topology Graph ───────────────────────────────────────────────────────────
|
||||
|
||||
def _get_topology() -> dict:
|
||||
"""
|
||||
Build a network graph of all agents and their connections.
|
||||
Returns {nodes: [...], edges: [...]} for D3.js visualization.
|
||||
"""
|
||||
# Nodes: Rose + all Tier-2 agents
|
||||
nodes = [
|
||||
{"id": "rose", "name": "Rose 🌹", "type": "orchestrator",
|
||||
"color": "#f44336", "domain": "Orchestrator"},
|
||||
]
|
||||
for agent_id, meta in TIER2_AGENTS.items():
|
||||
nodes.append({
|
||||
"id": agent_id,
|
||||
"name": f"{meta['emoji']} {meta['name']}",
|
||||
"type": "tier2",
|
||||
"color": meta["color"],
|
||||
"domain": meta["domain"],
|
||||
})
|
||||
|
||||
# Edges: Rose connects to all Tier-2 agents
|
||||
edges = []
|
||||
for agent_id in TIER2_AGENTS:
|
||||
edges.append({
|
||||
"source": "rose", "target": agent_id,
|
||||
"type": "orchestrates", "strength": 1,
|
||||
})
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
|
||||
|
||||
def get_topology() -> dict:
|
||||
"""API: GET /api/agents/topology — return agent network graph."""
|
||||
return _get_topology()
|
||||
|
||||
@@ -3,6 +3,55 @@ Phase 5 — Memory Search (ChromaDB)
|
||||
Appended to agents.py functions for Memory Search.
|
||||
"""
|
||||
import chromadb
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
HERMES_HOME = Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")))
|
||||
|
||||
|
||||
def _get_agent_soul(agent_id: str) -> str | None:
|
||||
"""
|
||||
Load soul.md for a specific agent.
|
||||
|
||||
Searches in this order:
|
||||
1. ~/.hermes/agents/{agent_id}/soul.md
|
||||
2. ~/.hermes/agents/{agent_id}/SOUL.md
|
||||
|
||||
Returns None if not found.
|
||||
"""
|
||||
if not agent_id or agent_id == "rose":
|
||||
return None # Rose uses the global HERMES_HOME/SOUL.md
|
||||
|
||||
for fname in ("soul.md", "SOUL.md"):
|
||||
path = HERMES_HOME / "agents" / agent_id / fname
|
||||
if path.exists():
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8").strip()
|
||||
if content:
|
||||
return content
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _get_agent_memory_context(agent_id: str, query: str, limit: int = 5) -> str | None:
|
||||
"""
|
||||
Build a memory context string by searching ChromaDB for the agent's memories.
|
||||
|
||||
Searches rose_memory collection filtered by topic matching "{agent_id}/".
|
||||
Returns formatted text block or None if nothing found.
|
||||
"""
|
||||
if not agent_id or agent_id == "rose":
|
||||
return None
|
||||
|
||||
matches = _search_agent_memory(agent_id, query, limit=limit)
|
||||
if not matches:
|
||||
return None
|
||||
|
||||
blocks = []
|
||||
for m in matches:
|
||||
blocks.append(f"## {m['topic']}\n{m['content'][:300]}")
|
||||
return "\n\n".join(blocks) if blocks else None
|
||||
|
||||
|
||||
def _get_chroma_client():
|
||||
|
||||
@@ -45,6 +45,7 @@ class Session:
|
||||
input_tokens: int=0, output_tokens: int=0, estimated_cost=None,
|
||||
personality=None,
|
||||
active_stream_id: str=None,
|
||||
agent: str=None,
|
||||
pending_user_message: str=None,
|
||||
pending_attachments=None,
|
||||
pending_started_at=None,
|
||||
@@ -68,6 +69,7 @@ class Session:
|
||||
self.estimated_cost = estimated_cost
|
||||
self.personality = personality
|
||||
self.active_stream_id = active_stream_id
|
||||
self.agent = agent
|
||||
self.pending_user_message = pending_user_message
|
||||
self.pending_attachments = pending_attachments or []
|
||||
self.pending_started_at = pending_started_at
|
||||
@@ -103,6 +105,7 @@ class Session:
|
||||
'title': self.title,
|
||||
'workspace': self.workspace,
|
||||
'model': self.model,
|
||||
'agent': getattr(self, 'agent', None),
|
||||
'message_count': len(self.messages),
|
||||
'created_at': self.created_at,
|
||||
'updated_at': self.updated_at,
|
||||
|
||||
275
api/routes.py
275
api/routes.py
@@ -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()
|
||||
|
||||
@@ -23,6 +23,7 @@ from api.config import (
|
||||
resolve_model_provider,
|
||||
)
|
||||
from api.helpers import redact_session_data
|
||||
from api.agents_memory import _get_agent_soul, _get_agent_memory_context
|
||||
|
||||
# Global lock for os.environ writes. Per-session locks (_agent_lock) prevent
|
||||
# concurrent runs of the SAME session, but two DIFFERENT sessions can still
|
||||
@@ -774,7 +775,7 @@ def _sse(handler, event, data):
|
||||
handler.wfile.flush()
|
||||
|
||||
|
||||
def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, attachments=None):
|
||||
def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, attachments=None, agent=None):
|
||||
"""Run agent in background thread, writing SSE events to STREAMS[stream_id]."""
|
||||
q = STREAMS.get(stream_id)
|
||||
if q is None:
|
||||
@@ -814,6 +815,8 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
s = get_session(session_id)
|
||||
s.workspace = str(Path(workspace).expanduser().resolve())
|
||||
s.model = model
|
||||
if agent:
|
||||
s.agent = agent
|
||||
|
||||
_agent_lock = _get_session_agent_lock(session_id)
|
||||
# TD1: set thread-local env context so concurrent sessions don't clobber globals
|
||||
@@ -1071,7 +1074,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
else:
|
||||
_fallback_resolved = None
|
||||
|
||||
agent = _AIAgent(
|
||||
_ai_agent = _AIAgent(
|
||||
model=resolved_model,
|
||||
provider=resolved_provider,
|
||||
base_url=resolved_base_url,
|
||||
@@ -1096,14 +1099,26 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
),
|
||||
)
|
||||
|
||||
# ── Per-agent identity: load soul.md + memory context ──────────────────
|
||||
if _ai_agent:
|
||||
_soul = _get_agent_soul(agent) # agent = selected agent_id string
|
||||
_mem_ctx = _get_agent_memory_context(agent, msg_text, limit=5)
|
||||
if _soul or _mem_ctx:
|
||||
_parts = []
|
||||
if _soul:
|
||||
_parts.append(f"=== AGENT IDENTITY: {agent.upper()} ===\n{_soul}")
|
||||
if _mem_ctx:
|
||||
_parts.append(f"=== PERTINENT MEMORY ===\n{_mem_ctx}")
|
||||
_ai_agent.ephemeral_system_prompt = "\n\n".join(_parts)
|
||||
|
||||
# Store agent instance for cancel/interrupt propagation
|
||||
with STREAMS_LOCK:
|
||||
AGENT_INSTANCES[stream_id] = agent
|
||||
AGENT_INSTANCES[stream_id] = _ai_agent
|
||||
# Check if cancel was requested during agent initialization
|
||||
if stream_id in CANCEL_FLAGS and CANCEL_FLAGS[stream_id].is_set():
|
||||
# Cancel arrived during agent creation - interrupt immediately
|
||||
try:
|
||||
agent.interrupt("Cancelled before start")
|
||||
_ai_agent.interrupt("Cancelled before start")
|
||||
except Exception:
|
||||
logger.debug("Failed to interrupt agent before start")
|
||||
put('cancel', {'message': 'Cancelled by user'})
|
||||
@@ -1143,9 +1158,9 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
_personality_prompt = str(_pval)
|
||||
# Pass personality via ephemeral_system_prompt (agent's own mechanism)
|
||||
if _personality_prompt:
|
||||
agent.ephemeral_system_prompt = _personality_prompt
|
||||
_ai_agent.ephemeral_system_prompt = _personality_prompt
|
||||
_previous_messages = list(s.messages or [])
|
||||
result = agent.run_conversation(
|
||||
result = _ai_agent.run_conversation(
|
||||
user_message=workspace_ctx + msg_text,
|
||||
system_message=workspace_system_msg,
|
||||
conversation_history=_sanitize_messages_for_api(s.messages),
|
||||
|
||||
@@ -309,20 +309,39 @@ def safe_resolve_ws(root: Path, requested: str) -> Path:
|
||||
return resolved
|
||||
|
||||
|
||||
def list_dir(workspace: Path, rel: str='.'):
|
||||
def list_dir(workspace: Path, rel: str='.', search: str=''):
|
||||
target = safe_resolve_ws(workspace, rel)
|
||||
if not target.is_dir():
|
||||
raise FileNotFoundError(f"Not a directory: {rel}")
|
||||
query = search.lower().strip()
|
||||
entries = []
|
||||
for item in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
|
||||
entries.append({
|
||||
'name': item.name,
|
||||
'path': str(item.relative_to(workspace)),
|
||||
'type': 'dir' if item.is_dir() else 'file',
|
||||
'size': item.stat().st_size if item.is_file() else None,
|
||||
})
|
||||
if len(entries) >= 200:
|
||||
break
|
||||
if query:
|
||||
# Recursive search
|
||||
try:
|
||||
for item in target.rglob('*'):
|
||||
if item.is_file():
|
||||
if query in item.name.lower():
|
||||
entries.append({
|
||||
'name': item.name,
|
||||
'path': str(item.relative_to(workspace)),
|
||||
'type': 'file',
|
||||
'size': item.stat().st_size,
|
||||
})
|
||||
if len(entries) >= 200:
|
||||
break
|
||||
except (PermissionError, OSError):
|
||||
pass
|
||||
entries.sort(key=lambda x: x['name'].lower())
|
||||
else:
|
||||
for item in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
|
||||
entries.append({
|
||||
'name': item.name,
|
||||
'path': str(item.relative_to(workspace)),
|
||||
'type': 'dir' if item.is_dir() else 'file',
|
||||
'size': item.stat().st_size if item.is_file() else None,
|
||||
})
|
||||
if len(entries) >= 200:
|
||||
break
|
||||
return entries
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user