Merge dev to main: Security improvements and comprehensive test infrastructure #39
@@ -6,7 +6,7 @@ from fastapi.responses import JSONResponse
|
|||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
from slowapi.errors import RateLimitExceeded
|
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 auth as auth_module
|
||||||
import config
|
import config
|
||||||
from rbac import require_page
|
from rbac import require_page
|
||||||
@@ -121,6 +121,14 @@ async def startup_event():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.getLogger(__name__).error("Failed to initialize OPC database: %s", 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())
|
asyncio.create_task(monitor_containers())
|
||||||
|
|
||||||
@app.get("/api/health")
|
@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)
|
# 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/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")
|
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from pydantic import BaseModel
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from db import opc_db
|
from db import opc_db
|
||||||
from rbac import require_page, User, _inject_user
|
from rbac import require_page, User, _inject_user
|
||||||
|
from routers import opc_ws
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -97,6 +98,9 @@ async def create_task(
|
|||||||
actor=user.username
|
actor=user.username
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Broadcast WebSocket update
|
||||||
|
await opc_ws.broadcast_task_update(created_task["id"], "created", created_task)
|
||||||
|
|
||||||
return created_task
|
return created_task
|
||||||
|
|
||||||
|
|
||||||
@@ -153,6 +157,10 @@ async def update_task(
|
|||||||
updates=updates,
|
updates=updates,
|
||||||
actor=user.username
|
actor=user.username
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Broadcast WebSocket update
|
||||||
|
await opc_ws.broadcast_task_update(task_id, "updated", updated_task)
|
||||||
|
|
||||||
return updated_task
|
return updated_task
|
||||||
|
|
||||||
|
|
||||||
@@ -163,6 +171,10 @@ async def delete_task(
|
|||||||
):
|
):
|
||||||
"""Delete a task"""
|
"""Delete a task"""
|
||||||
await opc_db.delete_task(task_id=task_id, actor=user.username)
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* OPC WebSocket Client - Real-time updates
|
||||||
|
*/
|
||||||
|
|
||||||
|
let ws = null;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
let listeners = new Set();
|
||||||
|
|
||||||
|
export function connect() {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/ws/opc`;
|
||||||
|
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log("OPC WebSocket connected");
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send ping every 30 seconds to keep connection alive
|
||||||
|
const pingInterval = setInterval(() => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send("ping");
|
||||||
|
} else {
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
notifyListeners(message);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse WebSocket message:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error("WebSocket error:", error);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log("OPC WebSocket disconnected");
|
||||||
|
ws = null;
|
||||||
|
|
||||||
|
// Reconnect after 5 seconds
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
console.log("Reconnecting WebSocket...");
|
||||||
|
connect();
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disconnect() {
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
ws = null;
|
||||||
|
}
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe(callback) {
|
||||||
|
listeners.add(callback);
|
||||||
|
return () => listeners.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyListeners(message) {
|
||||||
|
listeners.forEach((callback) => {
|
||||||
|
try {
|
||||||
|
callback(message);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Listener error:", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import KanbanBoard from "../components/opc/KanbanBoard.svelte";
|
import KanbanBoard from "../components/opc/KanbanBoard.svelte";
|
||||||
import TaskModal from "../components/opc/TaskModal.svelte";
|
import TaskModal from "../components/opc/TaskModal.svelte";
|
||||||
import { getTasks, getAgents } from "../lib/opc-api.js";
|
import { getTasks, getAgents } from "../lib/opc-api.js";
|
||||||
|
import * as opcWs from "../lib/opc-ws.js";
|
||||||
|
|
||||||
let tasks = $state([]);
|
let tasks = $state([]);
|
||||||
let agents = $state([]);
|
let agents = $state([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let showTaskModal = $state(false);
|
let showTaskModal = $state(false);
|
||||||
let editingTask = $state(null);
|
let editingTask = $state(null);
|
||||||
|
let unsubscribe = null;
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading = true;
|
loading = true;
|
||||||
@@ -26,6 +28,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleWebSocketMessage(message) {
|
||||||
|
if (message.type === "task_update") {
|
||||||
|
const { task_id, action, data } = message;
|
||||||
|
|
||||||
|
if (action === "created") {
|
||||||
|
tasks = [...tasks, data];
|
||||||
|
} else if (action === "updated" || action === "moved") {
|
||||||
|
tasks = tasks.map(t => t.id === task_id ? data : t);
|
||||||
|
} else if (action === "deleted") {
|
||||||
|
tasks = tasks.filter(t => t.id !== task_id);
|
||||||
|
}
|
||||||
|
} else if (message.type === "agent_execution") {
|
||||||
|
// Could show agent status in UI
|
||||||
|
console.log("Agent execution update:", message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleCreateTask() {
|
function handleCreateTask() {
|
||||||
editingTask = null;
|
editingTask = null;
|
||||||
showTaskModal = true;
|
showTaskModal = true;
|
||||||
@@ -68,6 +87,17 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadData();
|
loadData();
|
||||||
|
|
||||||
|
// Connect WebSocket
|
||||||
|
opcWs.connect();
|
||||||
|
unsubscribe = opcWs.subscribe(handleWebSocketMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (unsubscribe) {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
opcWs.disconnect();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Initialize OPC database in PostgreSQL
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Initializing OPC database..."
|
||||||
|
|
||||||
|
# Connect to PostgreSQL and create OPC database
|
||||||
|
ssh nas "/volume1/@appstore/ContainerManager/usr/bin/docker exec -i gitea-db psql -U gitea -d postgres" << 'EOF'
|
||||||
|
-- Create OPC database if it doesn't exist
|
||||||
|
SELECT 'CREATE DATABASE opc'
|
||||||
|
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'opc')\gexec
|
||||||
|
|
||||||
|
-- Grant permissions to gitea user
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE opc TO gitea;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "OPC database created successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo "1. The dashboard will auto-initialize tables on startup"
|
||||||
|
echo "2. Push your changes to trigger CI/CD deployment"
|
||||||
|
echo "3. Access OPC at https://nas.jimmygan.com/opc"
|
||||||
Reference in New Issue
Block a user