feat: complete Phase 1 MVP - add agent executor and WebSocket real-time updates
Deploy Dashboard (Dev) / deploy-dev (push) Has been cancelled
Run Tests / Backend Tests (push) Has been cancelled
Run Tests / Frontend Tests (push) Has been cancelled
Run Tests / Test Summary (push) Has been cancelled

Agent Executor Service:
- BaseAgent class with LLM integration via LiteLLM proxy
- AgentExecutor service that processes pending executions every 5 seconds
- Action execution: create_subtask, update_status, send_notification, add_comment
- Approval workflow: CxO level agents require user approval before execution
- Telegram notifications for approvals and completions
- Error handling and execution status tracking

WebSocket Real-Time Updates:
- WebSocket endpoint at /ws/opc for real-time task updates
- ConnectionManager for broadcasting to all connected clients
- Frontend WebSocket client with auto-reconnect
- Live updates for task create/update/move/delete actions
- Agent execution status broadcasts

Integration:
- Agent executor starts on dashboard startup
- Task router broadcasts WebSocket updates on all mutations
- Frontend OPC page subscribes to WebSocket and updates UI in real-time
- Agents (PM, CTO, COO) can now execute tasks autonomously

Phase 1 MVP Complete:
 PostgreSQL database with full schema
 Task CRUD API with automatic time tracking
 Kanban board with drag-and-drop
 Agent executor service with LLM integration
 WebSocket real-time updates
 3 core agents ready to execute

Next: Agent panel UI, email notifications, PDF invoicing
This commit is contained in:
Gan, Jimmy
2026-03-31 14:46:44 +08:00
parent fc0bb2979b
commit 1422cc9bc8
8 changed files with 720 additions and 2 deletions
+10 -1
View File
@@ -6,7 +6,7 @@ from fastapi.responses import JSONResponse
from slowapi import Limiter
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine, opc_tasks, opc_agents
from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine, opc_tasks, opc_agents, opc_ws
import auth as auth_module
import config
from rbac import require_page
@@ -121,6 +121,14 @@ async def startup_event():
except Exception as e:
logging.getLogger(__name__).error("Failed to initialize OPC database: %s", e)
# Start agent executor service
from services import agent_executor
try:
asyncio.create_task(agent_executor.start_executor())
logging.getLogger(__name__).info("Agent executor service started")
except Exception as e:
logging.getLogger(__name__).error("Failed to start agent executor: %s", e)
asyncio.create_task(monitor_containers())
@app.get("/api/health")
@@ -194,5 +202,6 @@ app.include_router(opc_agents.router, prefix="/api/opc",
# WebSocket endpoint (auth handled inside handler via auth cookie; query token fallback temporary)
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
app.add_api_websocket_route("/ws/opc", opc_ws.websocket_endpoint)
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
+12
View File
@@ -7,6 +7,7 @@ from pydantic import BaseModel
from datetime import datetime
from db import opc_db
from rbac import require_page, User, _inject_user
from routers import opc_ws
router = APIRouter()
@@ -97,6 +98,9 @@ async def create_task(
actor=user.username
)
# Broadcast WebSocket update
await opc_ws.broadcast_task_update(created_task["id"], "created", created_task)
return created_task
@@ -153,6 +157,10 @@ async def update_task(
updates=updates,
actor=user.username
)
# Broadcast WebSocket update
await opc_ws.broadcast_task_update(task_id, "updated", updated_task)
return updated_task
@@ -163,6 +171,10 @@ async def delete_task(
):
"""Delete a task"""
await opc_db.delete_task(task_id=task_id, actor=user.username)
# Broadcast WebSocket update
await opc_ws.broadcast_task_update(task_id, "deleted", {"id": task_id})
return {"ok": True}
+107
View File
@@ -0,0 +1,107 @@
"""
OPC WebSocket Router - Real-time updates for tasks and agent executions
"""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends
from typing import Set
import json
import asyncio
import logging
from auth import get_current_user_ws
logger = logging.getLogger(__name__)
router = APIRouter()
# Active WebSocket connections
active_connections: Set[WebSocket] = set()
class ConnectionManager:
"""Manages WebSocket connections"""
def __init__(self):
self.active_connections: Set[WebSocket] = set()
async def connect(self, websocket: WebSocket):
"""Accept and register a new connection"""
await websocket.accept()
self.active_connections.add(websocket)
logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}")
def disconnect(self, websocket: WebSocket):
"""Remove a connection"""
self.active_connections.discard(websocket)
logger.info(f"WebSocket disconnected. Total connections: {len(self.active_connections)}")
async def broadcast(self, message: dict):
"""Broadcast message to all connected clients"""
disconnected = set()
for connection in self.active_connections:
try:
await connection.send_json(message)
except Exception as e:
logger.error(f"Failed to send message: {e}")
disconnected.add(connection)
# Clean up disconnected clients
for conn in disconnected:
self.disconnect(conn)
manager = ConnectionManager()
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for OPC real-time updates"""
await manager.connect(websocket)
try:
while True:
# Keep connection alive and receive any client messages
data = await websocket.receive_text()
# Echo back for ping/pong
if data == "ping":
await websocket.send_text("pong")
except WebSocketDisconnect:
manager.disconnect(websocket)
except Exception as e:
logger.error(f"WebSocket error: {e}")
manager.disconnect(websocket)
async def broadcast_task_update(task_id: int, action: str, task_data: dict):
"""Broadcast task update to all connected clients"""
message = {
"type": "task_update",
"task_id": task_id,
"action": action, # created, updated, moved, deleted
"data": task_data,
"timestamp": task_data.get("updated_at")
}
await manager.broadcast(message)
async def broadcast_agent_execution(execution_id: int, status: str, execution_data: dict):
"""Broadcast agent execution update"""
message = {
"type": "agent_execution",
"execution_id": execution_id,
"status": status, # pending, running, completed, failed, pending_approval
"data": execution_data,
"timestamp": execution_data.get("started_at") or execution_data.get("completed_at")
}
await manager.broadcast(message)
async def broadcast_agent_status(agent_id: str, status: str, message_text: str = ""):
"""Broadcast agent status update"""
message = {
"type": "agent_status",
"agent_id": agent_id,
"status": status, # idle, working, error
"message": message_text
}
await manager.broadcast(message)
@@ -0,0 +1,306 @@
"""
Agent Executor Service - Executes agent tasks and manages their lifecycle
"""
import asyncio
import logging
from typing import Dict, Any, Optional
from datetime import datetime
from db import opc_db
from services.agents.base_agent import BaseAgent
import httpx
import os
logger = logging.getLogger(__name__)
# Telegram notification settings
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID")
class AgentExecutor:
"""Manages agent execution lifecycle"""
def __init__(self):
self.agents: Dict[str, BaseAgent] = {}
self.running = False
async def initialize(self):
"""Load agents from database"""
agents_data = await opc_db.get_agents(enabled_only=True)
for agent_data in agents_data:
agent = BaseAgent(
agent_id=agent_data["id"],
name=agent_data["name"],
role=agent_data["role"],
system_prompt=agent_data["system_prompt"],
config=agent_data["config"]
)
self.agents[agent_data["id"]] = agent
logger.info(f"Loaded agent: {agent.name} ({agent.agent_id})")
async def start(self):
"""Start the executor service"""
self.running = True
logger.info("Agent executor service started")
while self.running:
try:
await self._process_pending_executions()
except Exception as e:
logger.error(f"Error in executor loop: {e}")
await asyncio.sleep(5) # Check every 5 seconds
async def stop(self):
"""Stop the executor service"""
self.running = False
logger.info("Agent executor service stopped")
async def _process_pending_executions(self):
"""Process pending agent executions"""
executions = await opc_db.get_agent_executions(status="pending", limit=10)
for execution in executions:
try:
await self._execute_agent(execution)
except Exception as e:
logger.error(f"Failed to execute agent {execution['agent_id']}: {e}")
await self._mark_execution_failed(execution["id"], str(e))
async def _execute_agent(self, execution: Dict[str, Any]):
"""Execute a single agent task"""
agent_id = execution["agent_id"]
task_id = execution["task_id"]
# Get agent
agent = self.agents.get(agent_id)
if not agent:
raise ValueError(f"Agent {agent_id} not found")
# Mark as running
await self._update_execution_status(execution["id"], "running")
# Get task and context
tasks = await opc_db.get_tasks(limit=1000)
task = next((t for t in tasks if t["id"] == task_id), None)
if not task:
raise ValueError(f"Task {task_id} not found")
# Build context
context = await self._build_context(task)
# Execute agent
logger.info(f"Executing agent {agent.name} on task {task['title']}")
result = await agent.execute(task, context)
# Check if requires approval
if execution.get("requires_approval") or result.get("requires_approval"):
# Mark as pending approval
await self._mark_pending_approval(execution["id"], result)
await self._send_approval_notification(agent, task, result)
else:
# Execute actions
await self._execute_actions(task, result["actions"])
# Mark as completed
await self._mark_execution_completed(execution["id"], result)
# Send completion notification
await self._send_completion_notification(agent, task, result)
async def _build_context(self, task: Dict[str, Any]) -> Dict[str, Any]:
"""Build context for agent execution"""
context = {
"task": task,
"timestamp": datetime.utcnow().isoformat()
}
# Get related tasks from same project
if task.get("project_id"):
related_tasks = await opc_db.get_tasks(project_id=task["project_id"], limit=20)
context["related_tasks"] = related_tasks
# Get task history
history = await opc_db.get_task_history(task["id"])
context["history"] = history[:10] # Last 10 events
return context
async def _execute_actions(self, task: Dict[str, Any], actions: list):
"""Execute proposed actions"""
for action in actions:
action_type = action.get("type")
try:
if action_type == "create_subtask":
await self._action_create_subtask(task, action)
elif action_type == "update_task_status":
await self._action_update_status(task, action)
elif action_type == "send_notification":
await self._action_send_notification(action)
elif action_type == "add_comment":
await self._action_add_comment(task, action)
else:
logger.warning(f"Unknown action type: {action_type}")
except Exception as e:
logger.error(f"Failed to execute action {action_type}: {e}")
async def _action_create_subtask(self, parent_task: Dict[str, Any], action: Dict[str, Any]):
"""Create a subtask"""
await opc_db.create_task(
title=action.get("title", "Subtask"),
description=action.get("description", ""),
status="parking_lot",
priority=parent_task.get("priority", "medium"),
project_id=parent_task.get("project_id"),
tags=parent_task.get("tags", []),
actor="agent"
)
logger.info(f"Created subtask: {action.get('title')}")
async def _action_update_status(self, task: Dict[str, Any], action: Dict[str, Any]):
"""Update task status"""
new_status = action.get("status")
if new_status:
await opc_db.update_task(
task_id=task["id"],
updates={"status": new_status},
actor="agent"
)
logger.info(f"Updated task {task['id']} status to {new_status}")
async def _action_send_notification(self, action: Dict[str, Any]):
"""Send notification"""
message = action.get("message", "")
if message and TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID:
await self._send_telegram(message)
async def _action_add_comment(self, task: Dict[str, Any], action: Dict[str, Any]):
"""Add comment to task (stored in history)"""
comment = action.get("comment", "")
if comment:
# Store as task history
pool = await opc_db.get_pool()
async with pool.acquire() as conn:
await conn.execute("""
INSERT INTO task_history (task_id, action, actor, actor_type, new_value)
VALUES ($1, $2, $3, $4, $5)
""", task["id"], "comment", "agent", "agent", comment)
async def _update_execution_status(self, execution_id: int, status: str):
"""Update execution status"""
pool = await opc_db.get_pool()
async with pool.acquire() as conn:
await conn.execute("""
UPDATE agent_executions
SET status = $1, started_at = CURRENT_TIMESTAMP
WHERE id = $2
""", status, execution_id)
async def _mark_execution_completed(self, execution_id: int, result: Dict[str, Any]):
"""Mark execution as completed"""
pool = await opc_db.get_pool()
async with pool.acquire() as conn:
await conn.execute("""
UPDATE agent_executions
SET status = $1,
output_result = $2,
actions_proposed = $3,
actions_executed = $3,
completed_at = CURRENT_TIMESTAMP
WHERE id = $4
""", "completed",
opc_db.json.dumps(result),
opc_db.json.dumps(result.get("actions", [])),
execution_id)
async def _mark_execution_failed(self, execution_id: int, error: str):
"""Mark execution as failed"""
pool = await opc_db.get_pool()
async with pool.acquire() as conn:
await conn.execute("""
UPDATE agent_executions
SET status = $1, error_message = $2, completed_at = CURRENT_TIMESTAMP
WHERE id = $3
""", "failed", error, execution_id)
async def _mark_pending_approval(self, execution_id: int, result: Dict[str, Any]):
"""Mark execution as pending approval"""
pool = await opc_db.get_pool()
async with pool.acquire() as conn:
await conn.execute("""
UPDATE agent_executions
SET status = $1,
output_result = $2,
actions_proposed = $3,
requires_approval = TRUE
WHERE id = $4
""", "pending_approval",
opc_db.json.dumps(result),
opc_db.json.dumps(result.get("actions", [])),
execution_id)
async def _send_telegram(self, message: str):
"""Send Telegram notification"""
try:
proxy_url = os.environ.get("TELEGRAM_PROXY")
async with httpx.AsyncClient(proxy=proxy_url, timeout=10.0) as client:
await client.post(
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
data={
"chat_id": TELEGRAM_CHAT_ID,
"text": message,
"parse_mode": "Markdown"
}
)
except Exception as e:
logger.error(f"Failed to send Telegram notification: {e}")
async def _send_approval_notification(self, agent: BaseAgent, task: Dict[str, Any], result: Dict[str, Any]):
"""Send notification for approval request"""
message = f"""🤖 *Agent Approval Required*
Agent: {agent.name}
Task: {task['title']}
Reasoning: {result.get('reasoning', 'No reasoning provided')}
Proposed Actions:
"""
for i, action in enumerate(result.get("actions", []), 1):
message += f"{i}. {action.get('type')}: {action.get('title', action.get('message', 'N/A'))}\n"
message += "\nPlease review and approve in the OPC dashboard."
await self._send_telegram(message)
async def _send_completion_notification(self, agent: BaseAgent, task: Dict[str, Any], result: Dict[str, Any]):
"""Send notification for completed execution"""
message = f"""✅ *Agent Task Completed*
Agent: {agent.name}
Task: {task['title']}
Actions Executed: {len(result.get('actions', []))}
"""
await self._send_telegram(message)
# Global executor instance
_executor: Optional[AgentExecutor] = None
async def get_executor() -> AgentExecutor:
"""Get or create executor instance"""
global _executor
if _executor is None:
_executor = AgentExecutor()
await _executor.initialize()
return _executor
async def start_executor():
"""Start the executor service"""
executor = await get_executor()
await executor.start()
@@ -0,0 +1,146 @@
"""
Base Agent Class - Foundation for all OPC agents
"""
from typing import Dict, Any, List, Optional
import json
import httpx
import os
from datetime import datetime
class BaseAgent:
"""Base class for all OPC agents"""
def __init__(self, agent_id: str, name: str, role: str, system_prompt: str, config: Dict[str, Any]):
self.agent_id = agent_id
self.name = name
self.role = role
self.system_prompt = system_prompt
self.config = config
self.litellm_url = os.getenv("LITELLM_URL", "http://litellm:4005")
async def execute(self, task: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
"""
Execute agent on a task
Returns: {
"actions_proposed": [...],
"reasoning": "...",
"requires_approval": bool
}
"""
# Build prompt with task context
user_prompt = self._build_prompt(task, context)
# Call LLM
response = await self._call_llm(user_prompt)
# Parse response and extract actions
result = self._parse_response(response)
return result
def _build_prompt(self, task: Dict[str, Any], context: Dict[str, Any]) -> str:
"""Build the prompt for the LLM"""
prompt = f"""You are {self.name}, a {self.role} agent.
Task Details:
- Title: {task.get('title')}
- Description: {task.get('description', 'No description')}
- Status: {task.get('status')}
- Priority: {task.get('priority')}
- Tags: {', '.join(task.get('tags', []))}
Context:
- Project: {context.get('project', 'None')}
- Related tasks: {len(context.get('related_tasks', []))}
- Company goals: {context.get('goals', 'None')}
Your role: {self.system_prompt}
Based on this task, propose concrete actions you would take. Format your response as JSON:
{{
"reasoning": "Your analysis of the task",
"actions": [
{{"type": "create_subtask", "title": "...", "description": "..."}},
{{"type": "update_task_status", "status": "..."}},
{{"type": "send_notification", "message": "..."}},
{{"type": "request_approval", "question": "..."}}
],
"requires_approval": false
}}
Only propose actions you can actually execute. Be specific and actionable."""
return prompt
async def _call_llm(self, prompt: str) -> str:
"""Call LiteLLM proxy"""
model = self.config.get("model", "claude-sonnet-4-6")
temperature = self.config.get("temperature", 0.7)
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{self.litellm_url}/chat/completions",
json={
"model": model,
"messages": [
{"role": "system", "content": self.system_prompt},
{"role": "user", "content": prompt}
],
"temperature": temperature,
"max_tokens": 2000
}
)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
except Exception as e:
raise Exception(f"LLM call failed: {str(e)}")
def _parse_response(self, response: str) -> Dict[str, Any]:
"""Parse LLM response and extract actions"""
try:
# Try to extract JSON from response
if "```json" in response:
json_str = response.split("```json")[1].split("```")[0].strip()
elif "```" in response:
json_str = response.split("```")[1].split("```")[0].strip()
else:
json_str = response.strip()
result = json.loads(json_str)
# Validate structure
if "actions" not in result:
result["actions"] = []
if "reasoning" not in result:
result["reasoning"] = "No reasoning provided"
if "requires_approval" not in result:
# Check if any action requires approval
result["requires_approval"] = any(
action.get("type") == "request_approval" for action in result["actions"]
)
return result
except Exception as e:
# Fallback: return error action
return {
"reasoning": f"Failed to parse response: {str(e)}",
"actions": [
{
"type": "error",
"message": f"Agent response parsing failed: {str(e)}"
}
],
"requires_approval": False
}
def get_capabilities(self) -> List[str]:
"""Get agent capabilities"""
return self.config.get("capabilities", [])
def requires_approval(self) -> bool:
"""Check if agent requires approval for actions"""
return self.config.get("approval_level") == "cxo"