fischer-agentkit/src/agentkit/server/routes/portal.py

1121 lines
43 KiB
Python

import asyncio
import hmac
import json
import logging
import os
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from fastapi import (
APIRouter,
Depends,
HTTPException,
Request,
WebSocket,
WebSocketDisconnect,
Security,
)
from fastapi.security import APIKeyHeader, APIKeyQuery
from pydantic import BaseModel
from agentkit.core.config_driven import ConfigDrivenAgent
from agentkit.core.event_queue import EventQueue
from agentkit.core.protocol import Event, TaskEventType, TurnEventType
from agentkit.core.react import ReActEngine
from agentkit.chat.skill_routing import ExecutionMode, SkillRoutingResult
from agentkit.chat.request_preprocessor import RequestPreprocessor
from agentkit.server.routes.evolution_dashboard import (
_experiences as _dashboard_experiences,
DashboardExperience,
_broadcast_event as _broadcast_dashboard_event,
)
from agentkit.core.fallback import EMPTY_LLM_RESPONSE
from agentkit.chat.sqlite_conversation_store import SqliteConversationStore
logger = logging.getLogger(__name__)
router = APIRouter(tags=["portal"])
# Map ReAct engine event_type strings to TurnEventType constants for EQ emission.
# Only events with a corresponding TurnEventType are forwarded to the EQ;
# other events (e.g. "token") are still sent over WebSocket but not duplicated to EQ.
_REACT_EVENT_TYPE_MAP: dict[str, str] = {
"thinking": TurnEventType.THINKING,
"tool_call": TurnEventType.TOOL_CALL,
"tool_result": TurnEventType.TOOL_RESULT,
"final_answer": TurnEventType.FINAL_ANSWER,
}
# ---------------------------------------------------------------------------
# API Key Authentication
# ---------------------------------------------------------------------------
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
_api_key_query = APIKeyQuery(name="api_key", auto_error=False)
def _ensure_non_empty(text: str | None) -> str:
"""Ensure response text is never empty or whitespace-only."""
if text and text.strip():
return text
return EMPTY_LLM_RESPONSE
async def _emit_event_safe(
event_queue: EventQueue | None,
event_type: str,
task_id: str,
session_id: str,
data: dict | None = None,
) -> None:
"""Emit an event to the EventQueue without blocking or raising.
The EQ is a side-channel: emit failures must never break the WebSocket flow.
All exceptions are swallowed and logged at warning level.
Args:
event_queue: The EventQueue to emit to (no-op if None)
event_type: Event type (see TaskEventType / TurnEventType)
task_id: Associated task ID
session_id: Associated session ID (conversation_id)
data: Optional event payload
"""
if event_queue is None:
return
try:
event = Event.create(
event_type=event_type,
task_id=task_id,
session_id=session_id,
data=data or {},
)
await event_queue.emit(event)
except Exception as e:
logger.warning(f"EventQueue emit failed (type={event_type}): {e}", exc_info=True)
async def _verify_api_key(
request: Request,
api_key_header: str | None = Security(_api_key_header),
api_key_query: str | None = Security(_api_key_query),
) -> None:
"""Verify API key for REST endpoints. Raises HTTPException if invalid."""
configured_api_key: str | None = None
if hasattr(request.app.state, "server_config") and request.app.state.server_config:
configured_api_key = request.app.state.server_config.api_key
if configured_api_key is None and hasattr(request.app.state, "api_key"):
configured_api_key = request.app.state.api_key
# If no API key is configured, allow all requests (backwards compat)
if configured_api_key is None:
return
provided = api_key_header or api_key_query
if not hmac.compare_digest((provided or "").encode(), configured_api_key.encode()):
raise HTTPException(
status_code=401,
detail="Invalid or missing API key. Provide via X-API-Key header or api_key query parameter.",
)
# ---------------------------------------------------------------------------
# In-memory Conversation Store
# ---------------------------------------------------------------------------
@dataclass
class ChatMessage:
role: str # "user" or "assistant"
content: str
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
metadata: dict = field(default_factory=dict)
@dataclass
class Conversation:
id: str
messages: list[ChatMessage] = field(default_factory=list)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
# Heartbeat timeout in seconds — 0 disables timeout (for testing)
_WS_HEARTBEAT_TIMEOUT = float(os.environ.get("AGENTKIT_WS_TIMEOUT", "120"))
_conversation_store = SqliteConversationStore()
# ---------------------------------------------------------------------------
# History injection helper — configurable limit + optional compression
# ---------------------------------------------------------------------------
# Maximum history messages to inject (can be overridden by server config)
_MAX_HISTORY_MESSAGES = 50
async def _build_history_messages(
conv_id: str,
limit: int = _MAX_HISTORY_MESSAGES,
) -> list[dict]:
"""Build conversation history messages for LLM context injection.
Returns a list of {"role": "user"|"assistant", "content": ...} dicts
representing the conversation history (excluding the current user message,
which should be appended separately by the caller).
"""
try:
history = await _conversation_store.get_history(conv_id, limit=limit)
except Exception:
return []
# The last message in history is the current user message (just added),
# so skip it to avoid duplication.
messages = []
for hist_msg in history[:-1]:
if hist_msg.role in ("user", "assistant"):
messages.append({"role": hist_msg.role, "content": hist_msg.content})
return messages
# ---------------------------------------------------------------------------
# Capability mapping
# ---------------------------------------------------------------------------
CAPABILITY_CATEGORIES: dict[str, dict[str, str]] = {
"chat": {
"display_name": "智能对话",
"description": "自然语言交互,自动路由到对应能力",
"icon": "MessageOutlined",
},
"workflow": {
"display_name": "工作流编排",
"description": "可视化拖拽编排工作流",
"icon": "ApartmentOutlined",
},
"knowledge": {
"display_name": "知识库",
"description": "文档摄取、语义检索、多源RAG",
"icon": "BookOutlined",
},
"skills": {
"display_name": "技能管理",
"description": "浏览和管理已注册的技能",
"icon": "AppstoreOutlined",
},
"terminal": {
"display_name": "智能终端",
"description": "交互式终端会话和命令执行",
"icon": "CodeOutlined",
},
"computer_use": {
"display_name": "Computer Use",
"description": "UI自动化操作和截屏识别",
"icon": "DesktopOutlined",
},
"evolution": {
"display_name": "自进化",
"description": "经验积累、避坑预警、路径优化",
"icon": "RiseOutlined",
},
"settings": {
"display_name": "系统设置",
"description": "配置LLM、技能、知识库连接",
"icon": "SettingOutlined",
},
}
# ---------------------------------------------------------------------------
# Request / Response models
# ---------------------------------------------------------------------------
class ChatRequest(BaseModel):
message: str
conversation_id: str | None = None
sources: list[str] | None = None
skill_name: str | None = None
class ChatResponse(BaseModel):
conversation_id: str
message: str
timestamp: str
matched_skill: str | None = None
routing_method: str | None = None
confidence: float | None = None
task_id: str | None = None
status: str = "completed"
class CapabilityInfo(BaseModel):
name: str
display_name: str
description: str
icon: str
enabled: bool
skill_count: int
class CapabilitiesResponse(BaseModel):
capabilities: list[CapabilityInfo]
# ---------------------------------------------------------------------------
# Helper: resolve agent + skill for a chat request
# ---------------------------------------------------------------------------
async def _resolve_for_chat(
request: ChatRequest, req: Request
) -> tuple[
ConfigDrivenAgent | None, SkillRoutingResult | None, str | None, str | None, float | None
]:
"""Resolve agent and routing for a chat request via RequestPreprocessor.
Returns (agent, routing_result, matched_skill_name, routing_method, confidence).
"""
pool = req.app.state.agent_pool
skill_registry = req.app.state.skill_registry
request_preprocessor: RequestPreprocessor = req.app.state.request_preprocessor
matched_skill_name: str | None = None
routing_method: str | None = None
confidence: float | None = None
# Get default tools and system prompt
default_tools = []
default_system_prompt = None
default_agent = pool.get_agent("default")
if default_agent is not None:
default_tools = default_agent.get_tools()
default_system_prompt = (
getattr(default_agent, "_system_prompt", None) or default_agent.get_system_prompt()
)
else:
all_skills = skill_registry.list_skills()
for skill in all_skills:
agent = pool.get_agent(skill.name)
if agent is not None:
default_tools = agent.get_tools()
default_system_prompt = (
getattr(agent, "_system_prompt", None) or agent.get_system_prompt()
)
break
# If skill_name is explicitly provided in the request, use it directly
if request.skill_name:
routing_result = await request_preprocessor.preprocess(
content=f"@skill:{request.skill_name} {request.message}",
skill_registry=skill_registry,
default_tools=default_tools,
default_system_prompt=default_system_prompt,
default_model="default",
default_agent_name="default",
)
else:
# Preprocess via RequestPreprocessor (minimal: @skill prefix + greeting regex + REACT)
routing_result = await request_preprocessor.preprocess(
content=request.message,
skill_registry=skill_registry,
default_tools=default_tools,
default_system_prompt=default_system_prompt,
default_model="default",
default_agent_name="default",
)
matched_skill_name = routing_result.skill_name or routing_result.agent_name
routing_method = routing_result.match_method
confidence = routing_result.match_confidence
# Get or create agent based on routing result
if routing_result.matched and routing_result.skill_name:
agent = pool.get_agent(routing_result.skill_name)
if agent is None:
agent = await pool.create_agent_from_skill(routing_result.skill_name)
else:
agent = pool.get_agent("default")
if agent is None:
# Fallback: try to create from first available skill
all_skills = skill_registry.list_skills()
if all_skills:
agent = await pool.create_agent_from_skill(all_skills[0].name)
return agent, routing_result, matched_skill_name, routing_method, confidence
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.post("/portal/chat", response_model=ChatResponse)
async def chat(request: ChatRequest, req: Request, _auth: None = Depends(_verify_api_key)):
"""Send a chat message and get a response with RequestPreprocessor routing."""
# If skill_name is explicitly requested but not found, return 404
if request.skill_name:
skill_registry = req.app.state.skill_registry
if not skill_registry.has_skill(request.skill_name):
raise HTTPException(status_code=404, detail=f"Skill '{request.skill_name}' not found")
agent, routing_result, matched_skill, routing_method, confidence = await _resolve_for_chat(
request, req
)
# Create or reuse conversation
conv = await _conversation_store.get_or_create(request.conversation_id)
await _conversation_store.add_message(conv.id, "user", request.message)
llm_gateway = req.app.state.llm_gateway
task_id = str(uuid.uuid4())
response_text = ""
if routing_result is not None and routing_result.execution_mode == ExecutionMode.DIRECT_CHAT:
# DIRECT_CHAT: direct LLM call, no ReAct loop (same as WebSocket path)
chat_messages = []
if routing_result.system_prompt:
chat_messages.append({"role": "system", "content": routing_result.system_prompt})
chat_messages.append({"role": "user", "content": request.message})
# Inject conversation history
history_msgs = await _build_history_messages(conv.id)
for hm in history_msgs:
chat_messages.insert(-1, hm)
response = await llm_gateway.chat(
messages=chat_messages,
model=routing_result.model or "default",
agent_name="default",
task_type="chat",
)
response_text = _ensure_non_empty(response.content)
else:
# REACT / SKILL_REACT / REWOO / REFLEXION / PLAN_EXEC / TEAM_COLLAB
# Advanced modes (REWOO, REFLEXION, PLAN_EXEC, TEAM_COLLAB) currently
# fall back to REACT with a warning. Full integration is tracked separately.
if routing_result is not None and routing_result.execution_mode not in (
ExecutionMode.REACT,
ExecutionMode.SKILL_REACT,
):
logger.warning(
f"Execution mode {routing_result.execution_mode.value} not yet supported "
f"in portal REST, falling back to REACT"
)
react_config = agent.get_react_config()
react_engine = getattr(agent, "_react_engine", None)
if react_engine is None:
react_engine = ReActEngine(
llm_gateway=llm_gateway,
max_steps=react_config["max_steps"],
)
else:
react_engine.reset()
messages = [{"role": "user", "content": request.message}]
# Inject conversation history
history_msgs = await _build_history_messages(conv.id)
for hm in reversed(history_msgs):
messages.insert(0, hm)
tools = agent.get_tools()
model = agent.get_model()
system_prompt = getattr(agent, "_system_prompt", None) or agent.get_system_prompt()
timeout_seconds = react_config["timeout_seconds"]
collected_output: list[str] = []
try:
async for event in react_engine.execute_stream(
messages=messages,
tools=tools,
model=model,
agent_name=agent.name,
system_prompt=system_prompt,
timeout_seconds=timeout_seconds,
):
if event.event_type == "final_answer":
collected_output.append(event.data.get("output", ""))
except Exception as e:
response_text = f"执行出错: {e}"
else:
response_text = _ensure_non_empty(
"".join(collected_output) if collected_output else None
)
await _conversation_store.add_message(conv.id, "assistant", response_text)
return ChatResponse(
conversation_id=conv.id,
message=response_text,
timestamp=datetime.now(timezone.utc).isoformat(),
matched_skill=matched_skill,
routing_method=routing_method,
confidence=confidence,
task_id=task_id,
status="completed",
)
@router.post("/portal/chat/stream")
async def chat_stream(request: ChatRequest, req: Request, _auth: None = Depends(_verify_api_key)):
"""Stream chat responses via SSE with RequestPreprocessor routing."""
from sse_starlette.sse import EventSourceResponse
agent, routing_result, matched_skill, routing_method, confidence = await _resolve_for_chat(
request, req
)
# Create or reuse conversation
conv = await _conversation_store.get_or_create(request.conversation_id)
await _conversation_store.add_message(conv.id, "user", request.message)
llm_gateway = req.app.state.llm_gateway
async def event_generator():
# Send routing info as first event
yield {
"event": "routing",
"data": json.dumps(
{
"skill": matched_skill,
"method": routing_method,
"confidence": confidence,
}
),
}
if (
routing_result is not None
and routing_result.execution_mode == ExecutionMode.DIRECT_CHAT
):
# DIRECT_CHAT: direct LLM call, no ReAct loop
chat_messages = []
if routing_result.system_prompt:
chat_messages.append({"role": "system", "content": routing_result.system_prompt})
chat_messages.append({"role": "user", "content": request.message})
history_msgs = await _build_history_messages(conv.id)
for hm in history_msgs:
chat_messages.insert(-1, hm)
response = await llm_gateway.chat(
messages=chat_messages,
model=routing_result.model or "default",
agent_name="default",
task_type="chat",
)
response_text = _ensure_non_empty(response.content)
await _conversation_store.add_message(conv.id, "assistant", response_text)
yield {
"event": "final_answer",
"data": json.dumps(
{
"step": 0,
"data": {"output": response_text},
"timestamp": datetime.now(timezone.utc).isoformat(),
}
),
}
else:
# REACT / SKILL_REACT / REWOO / REFLEXION / PLAN_EXEC / TEAM_COLLAB
# Advanced modes fall back to REACT with a warning.
if routing_result is not None and routing_result.execution_mode not in (
ExecutionMode.REACT,
ExecutionMode.SKILL_REACT,
):
logger.warning(
f"Execution mode {routing_result.execution_mode.value} not yet supported "
f"in portal SSE, falling back to REACT"
)
react_config = agent.get_react_config()
react_engine = getattr(agent, "_react_engine", None)
if react_engine is None:
react_engine = ReActEngine(
llm_gateway=llm_gateway,
max_steps=react_config["max_steps"],
)
else:
react_engine.reset()
messages = [{"role": "user", "content": request.message}]
tools = agent.get_tools()
model = agent.get_model()
system_prompt = getattr(agent, "_system_prompt", None) or agent.get_system_prompt()
timeout_seconds = react_config["timeout_seconds"]
collected_output: list[str] = []
try:
async for event in react_engine.execute_stream(
messages=messages,
tools=tools,
model=model,
agent_name=agent.name,
system_prompt=system_prompt,
timeout_seconds=timeout_seconds,
):
if event.event_type == "final_answer":
collected_output.append(event.data.get("output", ""))
yield {
"event": event.event_type,
"data": json.dumps(
{
"step": event.step,
"data": event.data,
"timestamp": event.timestamp,
}
),
}
except Exception as e:
yield {
"event": "error",
"data": json.dumps({"error": str(e)}),
}
return
response_text = _ensure_non_empty(
"".join(collected_output) if collected_output else None
)
await _conversation_store.add_message(conv.id, "assistant", response_text)
return EventSourceResponse(event_generator())
@router.get("/portal/capabilities", response_model=CapabilitiesResponse)
async def get_capabilities(req: Request, _auth: None = Depends(_verify_api_key)):
"""List all available capabilities with their status."""
skill_registry = req.app.state.skill_registry
all_skills = skill_registry.list_skills()
# Build a map of capability tag -> skill count
cap_skill_counts: dict[str, int] = {}
for skill in all_skills:
for cap in skill.capabilities:
cap_skill_counts[cap.tag] = cap_skill_counts.get(cap.tag, 0) + 1
# Also count the skill itself toward "skills" category
cap_skill_counts["skills"] = cap_skill_counts.get("skills", 0) + 1
capabilities: list[CapabilityInfo] = []
for cat_name, cat_info in CAPABILITY_CATEGORIES.items():
skill_count = cap_skill_counts.get(cat_name, 0)
capabilities.append(
CapabilityInfo(
name=cat_name,
display_name=cat_info["display_name"],
description=cat_info["description"],
icon=cat_info["icon"],
enabled=True,
skill_count=skill_count,
)
)
return CapabilitiesResponse(capabilities=capabilities)
@router.get("/portal/conversations")
async def list_conversations(limit: int = 20, _auth: None = Depends(_verify_api_key)):
"""List recent conversations."""
convs = await _conversation_store.list_conversations(limit=limit)
return [
{
"id": c.id,
"title": _derive_conversation_title(c),
"created_at": c.created_at.isoformat(),
"updated_at": c.updated_at.isoformat(),
"message_count": len(c.messages),
}
for c in convs
]
def _derive_conversation_title(conv: Conversation) -> str:
"""Derive a human-readable title from the first user message."""
for msg in conv.messages:
if msg.role == "user" and msg.content:
return msg.content[:20] + ("..." if len(msg.content) > 20 else "")
return "对话"
@router.get("/portal/conversations/{conversation_id}")
async def get_conversation(
conversation_id: str, limit: int = 50, _auth: None = Depends(_verify_api_key)
):
"""Get conversation history from SQLite-backed store."""
history = await _conversation_store.get_history(conversation_id, limit=limit)
if not history:
raise HTTPException(status_code=404, detail=f"Conversation '{conversation_id}' not found")
conv = await _conversation_store.get_or_create(conversation_id)
return {
"id": conv.id,
"title": _derive_conversation_title(conv),
"messages": [
{
"id": f"{conv.id}-{i}",
"role": m.role,
"content": m.content,
"timestamp": m.timestamp.isoformat(),
"metadata": m.metadata,
}
for i, m in enumerate(history)
],
"created_at": conv.created_at.isoformat(),
"updated_at": conv.updated_at.isoformat(),
}
def _derive_title_from_messages(messages: list) -> str:
"""Derive title from a list of Message objects (SessionManager format)."""
for msg in messages:
if msg.role.value == "user" and msg.content:
return msg.content[:20] + ("..." if len(msg.content) > 20 else "")
return "对话"
@router.websocket("/portal/ws")
async def portal_websocket(websocket: WebSocket):
"""Real-time chat WebSocket endpoint."""
await websocket.accept()
# Authentication (after accept, since FastAPI requires accept before close)
configured_api_key: str | None = None
if hasattr(websocket.app.state, "server_config") and websocket.app.state.server_config:
configured_api_key = websocket.app.state.server_config.api_key
if configured_api_key is None and hasattr(websocket.app.state, "api_key"):
configured_api_key = websocket.app.state.api_key
# Check api_key query param
if configured_api_key:
provided = websocket.query_params.get("api_key")
if not hmac.compare_digest((provided or "").encode(), configured_api_key.encode()):
await websocket.send_json(
{"type": "error", "data": {"message": "Invalid or missing api_key"}}
)
await websocket.close(code=4001, reason="Invalid or missing api_key")
return
# Wait for first chat message before creating conversation
conv: Conversation | None = None
# task_id is per-user-message; tracked here so the outer except can emit task.failed
task_id: str | None = None
try:
while True:
try:
timeout = _WS_HEARTBEAT_TIMEOUT if _WS_HEARTBEAT_TIMEOUT > 0 else None
raw = await asyncio.wait_for(websocket.receive_text(), timeout=timeout)
except asyncio.TimeoutError:
await websocket.close(code=1000, reason="Heartbeat timeout")
return
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
msg_type = msg.get("type")
if msg_type == "cancel":
await websocket.send_json(
{
"type": "result",
"data": {
"status": "cancelled",
"timestamp": datetime.now(timezone.utc).isoformat(),
},
}
)
return
if msg_type == "ping":
await websocket.send_json({"type": "pong"})
continue
if msg_type != "chat":
continue
message_text = msg.get("message", "")
model_override = msg.get("model") # Frontend model selector
if not message_text:
continue
# Create conversation on first message (not on connect)
if conv is None:
conv_id = msg.get("conversation_id")
conv = await _conversation_store.get_or_create(conv_id)
await websocket.send_json({"type": "connected", "conversation_id": conv.id})
# Generate task_id for this user message and emit task.created to EQ
# (EQ is a side-channel: emit failures never break the WebSocket flow)
task_id = str(uuid.uuid4())
event_queue: EventQueue | None = getattr(websocket.app.state, "event_queue", None)
await _emit_event_safe(
event_queue,
TaskEventType.TASK_CREATED,
task_id=task_id,
session_id=conv.id,
data={"message": message_text},
)
# Add user message to conversation
await _conversation_store.add_message(conv.id, "user", message_text)
start_time = datetime.now(timezone.utc)
async def _record_experience(
task_type: str, goal: str, outcome: str, duration_seconds: float
) -> None:
"""Record experience to dashboard after chat completion."""
try:
exp = DashboardExperience(
id=str(uuid.uuid4()),
task_type=task_type,
goal=goal[:200],
outcome=outcome,
duration_seconds=duration_seconds,
created_at=datetime.now(timezone.utc),
)
_dashboard_experiences.append(exp)
await _broadcast_dashboard_event(
"experience_added",
{
"id": exp.id,
"task_type": exp.task_type,
"goal": exp.goal,
"outcome": exp.outcome,
},
)
await _broadcast_dashboard_event("metrics_updated", {"period": "7d"})
except Exception as e:
logger.warning(f"Failed to record experience: {e}")
# Unified preprocessing via RequestPreprocessor (minimal: @skill prefix + greeting regex + REACT)
pool = websocket.app.state.agent_pool
skill_registry = websocket.app.state.skill_registry
llm_gateway = websocket.app.state.llm_gateway
request_preprocessor: RequestPreprocessor = websocket.app.state.request_preprocessor
all_skills = skill_registry.list_skills()
# Get default tools for RequestPreprocessor
default_tools = []
default_system_prompt = None
default_agent = pool.get_agent("default")
if default_agent is not None:
default_tools = default_agent.get_tools()
default_system_prompt = (
getattr(default_agent, "_system_prompt", None)
or default_agent.get_system_prompt()
)
else:
for skill in all_skills:
agent = pool.get_agent(skill.name)
if agent is not None:
default_tools = agent.get_tools()
default_system_prompt = (
getattr(agent, "_system_prompt", None) or agent.get_system_prompt()
)
break
# Preprocess via RequestPreprocessor (minimal: @skill prefix + greeting regex + REACT)
routing_result = await request_preprocessor.preprocess(
content=message_text,
skill_registry=skill_registry,
default_tools=default_tools,
default_system_prompt=default_system_prompt,
default_model=model_override or "default",
default_agent_name="default",
)
await websocket.send_json(
{
"type": "routing",
"skill": routing_result.agent_name or "default",
"method": routing_result.match_method or "intent",
"confidence": routing_result.match_confidence,
}
)
# Emit task.started to EQ (execution begins after routing)
await _emit_event_safe(
event_queue,
TaskEventType.TASK_STARTED,
task_id=task_id,
session_id=conv.id,
data={
"agent_name": routing_result.agent_name or "default",
"execution_mode": routing_result.execution_mode.value
if hasattr(routing_result.execution_mode, "value")
else str(routing_result.execution_mode),
},
)
# Execute based on routing result's execution_mode
# This is the single source of truth for path selection,
# replacing fragile string-matching on match_method.
if routing_result.execution_mode == ExecutionMode.DIRECT_CHAT:
# Zero-cost path: direct LLM call, no ReAct loop
chat_messages = []
# Inject system prompt (contains SOUL/USER/MEMORY/DAILY) for identity continuity
if routing_result.system_prompt:
chat_messages.append(
{"role": "system", "content": routing_result.system_prompt}
)
chat_messages.append({"role": "user", "content": message_text})
# Inject conversation history for context continuity
history_msgs = await _build_history_messages(conv.id)
for hm in history_msgs:
chat_messages.insert(-1, hm)
response = await llm_gateway.chat(
messages=chat_messages,
model=model_override or "default",
agent_name="default",
task_type="chat",
)
# Store assistant reply for multi-turn context continuity
response_content = _ensure_non_empty(response.content)
await _conversation_store.add_message(conv.id, "assistant", response_content)
# Emit turn.final_answer and task.completed to EQ
await _emit_event_safe(
event_queue,
TurnEventType.FINAL_ANSWER,
task_id=task_id,
session_id=conv.id,
data={"output": response_content},
)
await _emit_event_safe(
event_queue,
TaskEventType.TASK_COMPLETED,
task_id=task_id,
session_id=conv.id,
data={"output": response_content},
)
await websocket.send_json(
{
"type": "result",
"data": {
"status": "completed",
"content": response_content,
"timestamp": datetime.now(timezone.utc).isoformat(),
},
}
)
await _record_experience(
"chat",
message_text,
"success",
(datetime.now(timezone.utc) - start_time).total_seconds(),
)
continue
# REACT / SKILL_REACT / REWOO / REFLEXION / PLAN_EXEC / TEAM_COLLAB
# Advanced modes fall back to REACT with a warning.
if routing_result.execution_mode not in (
ExecutionMode.REACT,
ExecutionMode.SKILL_REACT,
):
logger.warning(
f"Execution mode {routing_result.execution_mode.value} not yet supported "
f"in portal WebSocket, falling back to REACT"
)
agent_name = routing_result.agent_name or "default"
agent = pool.get_agent(agent_name)
if agent is None:
# Agent not in pool — fall back to direct chat.
# This handles the case where routing returned an agent_name
# that doesn't exist in the pool (e.g. "default" or a
# skill that hasn't been instantiated yet).
logger.info(
f"Session {conv.id}: agent '{agent_name}' not in pool, falling back to direct chat"
)
chat_messages = []
# Inject system prompt (contains SOUL/USER/MEMORY/DAILY) for identity continuity
if routing_result.system_prompt:
chat_messages.append(
{"role": "system", "content": routing_result.system_prompt}
)
chat_messages.append({"role": "user", "content": message_text})
try:
history = await _conversation_store.get_history(conv.id, limit=20)
for hist_msg in history[:-1]:
if hist_msg.role in ("user", "assistant"):
chat_messages.insert(
-1, {"role": hist_msg.role, "content": hist_msg.content}
)
except Exception:
pass
response = await llm_gateway.chat(
messages=chat_messages,
model=model_override or "default",
agent_name="default",
task_type="chat",
)
# Store assistant reply for multi-turn context continuity
response_content = _ensure_non_empty(response.content)
await _conversation_store.add_message(conv.id, "assistant", response_content)
# Emit turn.final_answer and task.completed to EQ (fallback path)
await _emit_event_safe(
event_queue,
TurnEventType.FINAL_ANSWER,
task_id=task_id,
session_id=conv.id,
data={"output": response_content},
)
await _emit_event_safe(
event_queue,
TaskEventType.TASK_COMPLETED,
task_id=task_id,
session_id=conv.id,
data={"output": response_content},
)
await websocket.send_json(
{
"type": "result",
"data": {
"status": "completed",
"content": response_content,
"timestamp": datetime.now(timezone.utc).isoformat(),
},
}
)
await _record_experience(
"chat",
message_text,
"success",
(datetime.now(timezone.utc) - start_time).total_seconds(),
)
continue
# Execute via ReAct stream
react_config = agent.get_react_config()
# Reuse agent's ReActEngine if available (aligned with chat.py pattern)
react_engine = getattr(agent, "_react_engine", None)
if react_engine is None:
react_engine = ReActEngine(
llm_gateway=llm_gateway,
max_steps=react_config["max_steps"],
)
else:
react_engine.reset()
messages = [{"role": "user", "content": message_text}]
# Inject conversation history for context continuity
history_msgs = await _build_history_messages(conv.id)
for hm in reversed(history_msgs):
messages.insert(0, hm)
tools = agent.get_tools()
model = model_override or agent.get_model()
system_prompt = getattr(agent, "_system_prompt", None) or agent.get_system_prompt()
timeout_seconds = react_config["timeout_seconds"]
logger.info(
f"[portal] agent='{agent_name}' tools={len(tools)} "
f"[{', '.join(t.name for t in tools)}] model={model}"
)
collected_output: list[str] = []
try:
async for event in react_engine.execute_stream(
messages=messages,
tools=tools,
model=model,
agent_name=agent.name,
system_prompt=system_prompt,
timeout_seconds=timeout_seconds,
):
if event.event_type == "final_answer":
collected_output.append(event.data.get("output", ""))
# Map ReAct event types to TurnEventType and emit to EQ
# (side-channel: failures are swallowed by _emit_event_safe)
_turn_event_type = _REACT_EVENT_TYPE_MAP.get(event.event_type)
if _turn_event_type is not None:
await _emit_event_safe(
event_queue,
_turn_event_type,
task_id=task_id,
session_id=conv.id,
data=event.data,
)
await websocket.send_json(
{
"type": "step",
"data": {
"event_type": event.event_type,
"step": event.step,
"data": event.data,
"timestamp": event.timestamp,
},
}
)
except Exception as e:
# Emit task.failed to EQ before sending error to WebSocket
await _emit_event_safe(
event_queue,
TaskEventType.TASK_FAILED,
task_id=task_id,
session_id=conv.id,
data={"error": str(e)},
)
await websocket.send_json({"type": "error", "data": {"message": str(e)}})
continue
response_text = _ensure_non_empty(
"".join(collected_output) if collected_output else None
)
await _conversation_store.add_message(conv.id, "assistant", response_text)
outcome = "success" if response_text != EMPTY_LLM_RESPONSE else "failure"
# Emit task.completed (success) or task.failed (empty response) to EQ
if outcome == "success":
await _emit_event_safe(
event_queue,
TaskEventType.TASK_COMPLETED,
task_id=task_id,
session_id=conv.id,
data={"output": response_text},
)
else:
await _emit_event_safe(
event_queue,
TaskEventType.TASK_FAILED,
task_id=task_id,
session_id=conv.id,
data={"error": "Empty LLM response"},
)
await websocket.send_json(
{
"type": "result",
"data": {
"message": response_text,
"timestamp": datetime.now(timezone.utc).isoformat(),
},
}
)
await _record_experience(
routing_result.skill_name or "agent",
message_text,
outcome,
(datetime.now(timezone.utc) - start_time).total_seconds(),
)
except WebSocketDisconnect:
logger.debug(f"Portal WebSocket disconnected for conversation {conv.id if conv else 'N/A'}")
except Exception as e:
logger.error(f"Portal WebSocket error: {e}")
# Emit task.failed to EQ if a task was in progress
# (task_id is set when a user message is received; None before that)
if task_id is not None and conv is not None:
event_queue = getattr(websocket.app.state, "event_queue", None)
await _emit_event_safe(
event_queue,
TaskEventType.TASK_FAILED,
task_id=task_id,
session_id=conv.id,
data={"error": str(e)},
)
try:
await websocket.send_json({"type": "error", "data": {"message": str(e)}})
except Exception:
pass