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

1622 lines
67 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, TaskStatus, 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
from agentkit.server.task_store import InMemoryTaskStore
logger = logging.getLogger(__name__)
router = APIRouter(tags=["portal"])
# Track background ReAct tasks so they are not garbage-collected mid-execution.
# Tasks are removed automatically via add_done_callback when they complete.
_running_background_tasks: set[asyncio.Task] = set()
# ---------------------------------------------------------------------------
# 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)
# P1 #14 fix: TaskStore sync/async compatibility shim.
# InMemoryTaskStore methods are sync; RedisTaskStore methods are async.
# These helpers detect and await coroutines so portal.py works with both.
async def _task_store_create(store, *args, **kwargs):
result = store.create(*args, **kwargs)
if asyncio.iscoroutine(result):
return await result
return result
async def _task_store_get(store, *args, **kwargs):
result = store.get(*args, **kwargs)
if asyncio.iscoroutine(result):
return await result
return result
async def _task_store_update_status(store, *args, **kwargs):
result = store.update_status(*args, **kwargs)
if asyncio.iscoroutine(result):
return await result
return result
async def _task_store_list_tasks(store, *args, **kwargs):
result = store.list_tasks(*args, **kwargs)
if asyncio.iscoroutine(result):
return await result
return result
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()
# P1 #9 fix: ReAct event type -> TurnEventType mapping for EQ subscribers.
# Preserves the original EQ contract so CLI and other subscribers that
# filter on TurnEventType constants (e.g. 'turn.thinking') keep working.
_REACT_EVENT_TYPE_MAP: dict[str, str] = {
"thinking": TurnEventType.THINKING,
"tool_call": TurnEventType.TOOL_CALL,
"tool_result": TurnEventType.TOOL_RESULT,
"token": TurnEventType.TOKEN,
"final_answer": TurnEventType.FINAL_ANSWER,
"error": TurnEventType.TURN_COMPLETED, # best-effort mapping
"confirmation_request": TurnEventType.STEP,
}
# ---------------------------------------------------------------------------
# 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 "对话"
async def _execute_react_background(
react_engine: ReActEngine,
messages: list[dict],
tools: list,
model: str,
agent_name: str,
system_prompt: str | None,
timeout_seconds: float | None,
conv_id: str,
task_id: str,
event_queue: EventQueue,
conversation_store: SqliteConversationStore,
task_store: InMemoryTaskStore | None = None,
) -> None:
"""Execute ReAct engine in the background, decoupled from WebSocket lifecycle.
Events are emitted to the EventQueue (filtered by task_id) so that any
subscriber — including a reconnected WebSocket — can consume them.
Results are always persisted to the conversation store, regardless of
whether a WebSocket subscriber is active.
Task status is tracked in TaskStore when provided.
"""
collected_output: list[str] = []
try:
if task_store is not None:
try:
await _task_store_update_status(
task_store, task_id, TaskStatus.RUNNING, started_at=datetime.now(timezone.utc)
)
except Exception:
logger.warning("Failed to update TaskStore RUNNING", exc_info=True)
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", ""))
# P1 #8/#9/#10 fix: Preserve TurnEventType mapping, step field,
# and original data structure for EQ subscriber compatibility.
# Note: Event dataclass has no 'step' field; use getattr for
# compatibility with ReActEngine events that may include it.
_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,
"step": getattr(event, "step", 0),
"timestamp": event.timestamp,
},
)
# Normal completion: persist result
response_text = _ensure_non_empty("".join(collected_output) if collected_output else None)
await conversation_store.add_message(conv_id, "assistant", response_text)
if task_store is not None:
try:
await _task_store_update_status(
task_store,
task_id,
TaskStatus.COMPLETED,
output_data={"output": response_text},
completed_at=datetime.now(timezone.utc),
progress=1.0,
progress_message="Completed",
)
except Exception:
logger.warning("Failed to update TaskStore COMPLETED", exc_info=True)
# Emit task.completed so subscribers know the task is done
await _emit_event_safe(
event_queue,
TaskEventType.TASK_COMPLETED,
task_id=task_id,
session_id=conv_id,
data={"output": response_text, "timestamp": datetime.now(timezone.utc).isoformat()},
)
except asyncio.CancelledError:
# Application shutdown or explicit cancel — persist partial output
# and mark task as FAILED so resume does not block forever.
# P0 #1/#2 fix: ALL persistence operations must use asyncio.shield
# and the async TaskStore shim. Without shield, a re-entrant
# cancellation kills the cleanup itself; without the shim,
# RedisTaskStore (async) silently drops the coroutine.
if collected_output:
partial = _ensure_non_empty("".join(collected_output))
try:
await asyncio.shield(conversation_store.add_message(conv_id, "assistant", partial))
except (Exception, asyncio.CancelledError):
logger.warning("Failed to persist partial output on cancel")
if task_store is not None:
try:
await asyncio.shield(
_task_store_update_status(
task_store,
task_id,
TaskStatus.FAILED,
error_message="Task cancelled",
completed_at=datetime.now(timezone.utc),
)
)
except (Exception, asyncio.CancelledError):
logger.warning("Failed to update TaskStore on cancel", exc_info=True)
# P0 #2 fix: _emit_event_safe is async (it awaits event_queue.emit).
# Shield it so a re-entrant CancelledError doesn't kill the emit
# and leave subscribers blocked until timeout.
try:
await asyncio.shield(
_emit_event_safe(
event_queue,
TaskEventType.TASK_FAILED,
task_id=task_id,
session_id=conv_id,
data={
"error": "Task cancelled",
"timestamp": datetime.now(timezone.utc).isoformat(),
},
)
)
except (Exception, asyncio.CancelledError):
logger.warning("Failed to emit TASK_FAILED on cancel")
raise # Propagate cancellation
except Exception as e:
# Persist any partial output collected before the error
if collected_output:
partial = _ensure_non_empty("".join(collected_output))
try:
await conversation_store.add_message(conv_id, "assistant", partial)
except Exception:
logger.warning("Failed to persist partial output in background task")
if task_store is not None:
try:
await _task_store_update_status(
task_store,
task_id,
TaskStatus.FAILED,
error_message=str(e),
completed_at=datetime.now(timezone.utc),
)
except Exception:
logger.warning("Failed to update TaskStore FAILED", exc_info=True)
# Emit task.failed so subscribers know the task failed
await _emit_event_safe(
event_queue,
TaskEventType.TASK_FAILED,
task_id=task_id,
session_id=conv_id,
data={"error": str(e), "timestamp": datetime.now(timezone.utc).isoformat()},
)
@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
# Track the active background task so cancel can propagate to it.
active_bg_task: asyncio.Task | 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":
# Cancel the active background task if still running
if active_bg_task is not None and not active_bg_task.done():
active_bg_task.cancel()
active_bg_task = None
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 == "resume":
# Frontend reconnected and wants to resume a running task
resume_task_id = msg.get("task_id", "")
if not resume_task_id:
continue
# P1 #3/#4 fix: Fail-closed ownership verification.
# Require conversation_id and TaskStore — reject if either
# is missing, to prevent cross-conversation task hijacking
# via empty conversation_id or unconfigured TaskStore.
resume_conv_id = msg.get("conversation_id", "")
if not resume_conv_id:
await websocket.send_json(
{
"type": "error",
"data": {
"message": "Resume requires conversation_id.",
"task_id": resume_task_id,
},
}
)
continue
resume_task_store: InMemoryTaskStore | None = getattr(
websocket.app.state, "task_store", None
)
resume_eq: EventQueue | None = getattr(websocket.app.state, "event_queue", None)
# P1 #4: Fail-closed if TaskStore is unavailable — cannot
# verify ownership without it.
if resume_task_store is None:
await websocket.send_json(
{
"type": "error",
"data": {
"message": "Resume not supported (TaskStore unavailable). Please retry your request.",
"task_id": resume_task_id,
},
}
)
continue
try:
record = await _task_store_get(resume_task_store, resume_task_id)
except Exception:
logger.warning("TaskStore.get failed during resume", exc_info=True)
record = None
if record is not None:
# P1 #3: Fail-closed ownership check — reject if
# conversation_id is missing from task metadata OR
# does not match the request.
task_conv_id = (record.metadata or {}).get("conversation_id", "")
if not task_conv_id or resume_conv_id != task_conv_id:
logger.warning(
"Resume rejected: conversation_id mismatch "
"(task=%s, request=%s, task_id=%s)",
task_conv_id,
resume_conv_id,
resume_task_id,
)
await websocket.send_json(
{
"type": "error",
"data": {
"message": "Task does not belong to this conversation.",
"task_id": resume_task_id,
},
}
)
continue
if record.status == TaskStatus.COMPLETED:
# Task already finished — send result immediately
output = (record.output_data or {}).get("output", "")
await websocket.send_json(
{
"type": "result",
"data": {
"message": output,
"timestamp": record.completed_at.isoformat()
if record.completed_at
else datetime.now(timezone.utc).isoformat(),
},
}
)
continue
elif record.status == TaskStatus.FAILED:
await websocket.send_json(
{
"type": "error",
"data": {
"message": record.error_message or "Task failed",
},
}
)
continue
else:
# Task not found in store — cannot resume
await websocket.send_json(
{
"type": "error",
"data": {
"message": "Task not found or has expired. Please retry your request.",
"task_id": resume_task_id,
},
}
)
continue
# Task is still running — subscribe to EventQueue for remaining events.
# H6: if EventQueue is unavailable, inform the client instead of
# silently continuing (which would leave the UI loading forever).
if resume_eq is None:
await websocket.send_json(
{
"type": "error",
"data": {
"message": "Resume not supported (EventQueue unavailable). Please retry your request.",
},
}
)
continue
# C2: bound the subscribe loop with a timeout so a dead
# background task cannot block resume forever.
resume_timeout = _WS_HEARTBEAT_TIMEOUT * 10 if _WS_HEARTBEAT_TIMEOUT > 0 else 600
try:
async with asyncio.timeout(resume_timeout):
async for event in resume_eq.subscribe(task_id=resume_task_id):
if event.event_type == TaskEventType.TASK_COMPLETED:
response_text = event.data.get("output", EMPTY_LLM_RESPONSE)
await websocket.send_json(
{
"type": "result",
"data": {
"message": response_text,
"timestamp": event.data.get(
"timestamp",
datetime.now(timezone.utc).isoformat(),
),
},
}
)
break
elif event.event_type == TaskEventType.TASK_FAILED:
await websocket.send_json(
{
"type": "error",
"data": {
"message": event.data.get("error", "Unknown error"),
},
}
)
break
else:
# P1 #8/#10 fix: step and data are now
# top-level fields in event.data.
await websocket.send_json(
{
"type": "step",
"data": {
"event_type": event.event_type,
"step": event.data.get("step", 0),
"data": {
k: v
for k, v in event.data.items()
if k not in ("step", "timestamp")
},
"timestamp": event.data.get("timestamp", ""),
},
}
)
except TimeoutError:
logger.warning(f"Resume subscribe timed out for task {resume_task_id}")
await websocket.send_json(
{
"type": "error",
"data": {
"message": "Task resume timed out. Please retry your request.",
"task_id": resume_task_id,
},
}
)
except RuntimeError as exc:
# P1 #5: subscriber limit reached or EQ closed — send
# a friendly error instead of terminating the connection.
logger.warning("Resume subscribe failed for task %s: %s", resume_task_id, exc)
await websocket.send_json(
{
"type": "error",
"data": {
"message": "Server busy, please retry shortly.",
"task_id": resume_task_id,
},
}
)
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 or switch conversation based on conversation_id from frontend
conv_id = msg.get("conversation_id")
if conv_id:
if conv is None or conv.id != conv_id:
conv = await _conversation_store.get_or_create(conv_id)
await websocket.send_json({"type": "connected", "conversation_id": conv.id})
elif conv is None:
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)
task_store: InMemoryTaskStore | None = getattr(websocket.app.state, "task_store", 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),
},
)
# Register task in TaskStore for status tracking and recovery
if task_store is not None:
try:
await _task_store_create(
task_store,
task_id=task_id,
agent_name=routing_result.agent_name or "default",
input_data={"message": message_text},
skill_name=routing_result.skill_name,
)
# Store conversation_id in metadata for frontend recovery
await _task_store_update_status(
task_store,
task_id,
TaskStatus.PENDING,
metadata={"conversation_id": conv.id},
)
except Exception:
logger.warning("Failed to register task in TaskStore", exc_info=True)
# 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)
# Update TaskStore status to COMPLETED
if task_store is not None:
try:
await _task_store_update_status(
task_store,
task_id,
TaskStatus.COMPLETED,
output_data={"output": response_content},
completed_at=datetime.now(timezone.utc),
progress=1.0,
progress_message="Completed",
)
except Exception:
logger.warning("Failed to update TaskStore for DIRECT_CHAT", exc_info=True)
# 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": {
"message": 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}"
)
# Start ReAct execution as a background task, decoupled from
# WebSocket lifecycle. When the WebSocket disconnects, the
# background task continues running and persists the result.
bg_task = asyncio.create_task(
_execute_react_background(
react_engine=react_engine,
messages=messages,
tools=tools,
model=model,
agent_name=agent.name,
system_prompt=system_prompt,
timeout_seconds=timeout_seconds,
conv_id=conv.id,
task_id=task_id,
event_queue=event_queue,
conversation_store=_conversation_store,
task_store=task_store,
)
)
_running_background_tasks.add(bg_task)
bg_task.add_done_callback(_running_background_tasks.discard)
active_bg_task = bg_task
# C1 guard: EventQueue is required for subscribe; fall back to
# awaiting the background task directly if unavailable.
if event_queue is None:
logger.warning("EventQueue not configured; awaiting background task directly")
try:
await bg_task
except Exception:
pass # errors handled inside _execute_react_background
active_bg_task = None
continue
# Subscribe to EventQueue (filtered by task_id) and forward
# events to the WebSocket. When the WebSocket disconnects,
# this loop exits but the background task continues.
# P1 #7 fix: bound the subscribe loop with a timeout so a
# hung background task cannot block the WebSocket forever.
# Matches the resume path's timeout strategy.
_subscribe_timeout = _WS_HEARTBEAT_TIMEOUT * 10 if _WS_HEARTBEAT_TIMEOUT > 0 else 600
try:
async with asyncio.timeout(_subscribe_timeout):
async for event in event_queue.subscribe(task_id=task_id):
if event.event_type == TaskEventType.TASK_COMPLETED:
response_text = event.data.get("output", EMPTY_LLM_RESPONSE)
await websocket.send_json(
{
"type": "result",
"data": {
"message": response_text,
"timestamp": event.data.get(
"timestamp",
datetime.now(timezone.utc).isoformat(),
),
},
}
)
await _record_experience(
routing_result.skill_name or "agent",
message_text,
"success" if response_text != EMPTY_LLM_RESPONSE else "failure",
(datetime.now(timezone.utc) - start_time).total_seconds(),
)
break
elif event.event_type == TaskEventType.TASK_FAILED:
await websocket.send_json(
{
"type": "error",
"data": {
"message": event.data.get("error", "Unknown error"),
},
}
)
await _record_experience(
routing_result.skill_name or "agent",
message_text,
"failure",
(datetime.now(timezone.utc) - start_time).total_seconds(),
)
break
else:
# Forward ReAct events as step messages.
# P1 #8/#10 fix: step and data are now top-level
# fields in event.data (no longer nested).
await websocket.send_json(
{
"type": "step",
"data": {
"event_type": event.event_type,
"step": event.data.get("step", 0),
"data": {
k: v
for k, v in event.data.items()
if k not in ("step", "timestamp")
},
"timestamp": event.data.get("timestamp", ""),
},
}
)
except TimeoutError:
logger.warning(f"Subscribe loop timed out for task {task_id}")
if active_bg_task is not None and not active_bg_task.done():
active_bg_task.cancel()
await websocket.send_json(
{
"type": "error",
"data": {
"message": "Task timed out. Please retry your request.",
"task_id": task_id,
},
}
)
except RuntimeError as exc:
# P1 #5: subscriber limit reached or EQ closed — send
# a friendly error instead of terminating the connection.
logger.warning("Subscribe failed for task %s: %s", task_id, exc)
await websocket.send_json(
{
"type": "error",
"data": {
"message": "Server busy, please retry shortly.",
"task_id": task_id,
},
}
)
except WebSocketDisconnect:
logger.debug(f"Portal WebSocket disconnected for conversation {conv.id if conv else 'N/A'}")
# P0 fix: Do NOT cancel the background task on disconnect.
# The entire purpose of the three-layer defense is to let the
# background task continue running and persist the result so the
# frontend can resume it after reconnection. Cancelling here would
# kill the task, lose the full output, and mark it FAILED —
# defeating layers 2 and 3. The task is only cancelled on explicit
# user cancel (msg_type == 'cancel') or application shutdown.
except Exception as e:
logger.error(f"Portal WebSocket error: {e}")
# P1 #6 fix: Do NOT cancel the background task on connection-level
# errors (ConnectionResetError, BrokenPipeError, etc.). These are
# functionally equivalent to WebSocketDisconnect — the client dropped
# — and the background task must survive to persist its result.
# Only cancel on truly unexpected errors that may have corrupted
# state needed by the background task.
if not isinstance(e, (ConnectionResetError, BrokenPipeError, ConnectionError)):
if active_bg_task is not None and not active_bg_task.done():
active_bg_task.cancel()
# 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