diff --git a/agent_os/backend/routes/runs.py b/agent_os/backend/routes/runs.py index d95751bf..e81edac3 100644 --- a/agent_os/backend/routes/runs.py +++ b/agent_os/backend/routes/runs.py @@ -2,14 +2,12 @@ from fastapi import APIRouter, Depends, BackgroundTasks, HTTPException from typing import Dict, Any, List import uuid import time +from agent_os.backend.store import runs from agent_os.backend.dependencies import get_current_user from agent_os.backend.services.langgraph_engine import LangGraphEngine router = APIRouter(prefix="/api/run", tags=["runs"]) -# In-memory store for demo (should be replaced by Redis/DB for persistence) -runs: Dict[str, Dict[str, Any]] = {} - engine = LangGraphEngine() @router.post("/scan") @@ -24,7 +22,8 @@ async def trigger_scan( "type": "scan", "status": "queued", "created_at": time.time(), - "user_id": user["user_id"] + "user_id": user["user_id"], + "params": params or {} } background_tasks.add_task(engine.run_scan, run_id, params or {}) return {"run_id": run_id, "status": "queued"} @@ -41,7 +40,8 @@ async def trigger_pipeline( "type": "pipeline", "status": "queued", "created_at": time.time(), - "user_id": user["user_id"] + "user_id": user["user_id"], + "params": params or {} } background_tasks.add_task(engine.run_pipeline, run_id, params or {}) return {"run_id": run_id, "status": "queued"} @@ -58,7 +58,8 @@ async def trigger_portfolio( "type": "portfolio", "status": "queued", "created_at": time.time(), - "user_id": user["user_id"] + "user_id": user["user_id"], + "params": params or {} } background_tasks.add_task(engine.run_portfolio, run_id, params or {}) return {"run_id": run_id, "status": "queued"} @@ -75,7 +76,8 @@ async def trigger_auto( "type": "auto", "status": "queued", "created_at": time.time(), - "user_id": user["user_id"] + "user_id": user["user_id"], + "params": params or {} } background_tasks.add_task(engine.run_auto, run_id, params or {}) return {"run_id": run_id, "status": "queued"} diff --git a/agent_os/backend/routes/websocket.py b/agent_os/backend/routes/websocket.py index a9aabd7b..9cf43592 100644 --- a/agent_os/backend/routes/websocket.py +++ b/agent_os/backend/routes/websocket.py @@ -4,106 +4,53 @@ import time import uuid from typing import Dict, Any from agent_os.backend.dependencies import get_current_user +from agent_os.backend.store import runs +from agent_os.backend.services.langgraph_engine import LangGraphEngine router = APIRouter(prefix="/ws", tags=["websocket"]) +engine = LangGraphEngine() + @router.websocket("/stream/{run_id}") async def websocket_endpoint( websocket: WebSocket, run_id: str, - # user: dict = Depends(get_current_user) # In V2, validate token from query string ): await websocket.accept() print(f"WebSocket client connected to run: {run_id}") + if run_id not in runs: + await websocket.send_json({"type": "system", "message": f"Error: Run {run_id} not found."}) + await websocket.close() + return + + run_info = runs[run_id] + run_type = run_info["type"] + params = run_info.get("params", {}) + try: - # For now, we use a mock stream. - # In a real implementation, this would subscribe to an event queue or a database stream - # that's being populated by the BackgroundTask running the LangGraph. + stream_gen = None + if run_type == "scan": + stream_gen = engine.run_scan(run_id, params) + elif run_type == "pipeline": + stream_gen = engine.run_pipeline(run_id, params) + # Add other types as they are implemented in LangGraphEngine - mock_events = [ - { - "id": "node_1", - "node_id": "analyst_node", - "parent_node_id": "start", - "type": "thought", - "agent": "ANALYST", - "message": "Evaluating market data...", - "metrics": { - "model": "gpt-4-turbo", - "tokens_in": 120, - "tokens_out": 45, - "latency_ms": 450 - } - }, - { - "id": "node_2", - "node_id": "tool_node", - "parent_node_id": "analyst_node", - "type": "tool", - "agent": "ANALYST", - "message": "> Tool Call: get_news_sentiment", - "metrics": { - "latency_ms": 800 - } - }, - { - "id": "node_3", - "node_id": "research_node", - "parent_node_id": "analyst_node", - "type": "thought", - "agent": "RESEARCHER", - "message": "Synthesizing industry trends...", - "metrics": { - "model": "claude-3-opus", - "tokens_in": 800, - "tokens_out": 300, - "latency_ms": 2200 - } - }, - { - "id": "node_4", - "node_id": "trader_node", - "parent_node_id": "research_node", - "type": "result", - "agent": "TRADER", - "message": "Action determined: BUY VLO", - "details": { - "model_used": "gpt-4-turbo", - "latency_ms": 1200, - "input_tokens": 450, - "output_tokens": 120, - "raw_json_response": '{"action": "buy", "ticker": "VLO"}' - }, - "metrics": { - "model": "gpt-4-turbo", - "tokens_in": 450, - "tokens_out": 120, - "latency_ms": 1200 - } - } - ] - - for evt in mock_events: - payload = { - "id": evt["id"], - "node_id": evt["node_id"], - "parent_node_id": evt["parent_node_id"], - "timestamp": time.strftime("%H:%M:%S"), - "agent": evt["agent"], - "tier": "mid" if evt["agent"] == "ANALYST" else "deep", - "type": evt["type"], - "message": evt["message"], - "details": evt.get("details"), - "metrics": evt.get("metrics") - } - await websocket.send_json(payload) - await asyncio.sleep(2) # Simulating execution delay + if stream_gen: + async for payload in stream_gen: + # Add timestamp if not present + if "timestamp" not in payload: + payload["timestamp"] = time.strftime("%H:%M:%S") + await websocket.send_json(payload) + else: + await websocket.send_json({"type": "system", "message": f"Error: Run type {run_type} streaming not yet implemented."}) await websocket.send_json({"type": "system", "message": "Run completed."}) except WebSocketDisconnect: print(f"WebSocket client disconnected from run {run_id}") except Exception as e: + import traceback + traceback.print_exc() await websocket.send_json({"type": "system", "message": f"Error: {str(e)}"}) await websocket.close() diff --git a/agent_os/backend/services/langgraph_engine.py b/agent_os/backend/services/langgraph_engine.py index 90416803..3e9ceb3e 100644 --- a/agent_os/backend/services/langgraph_engine.py +++ b/agent_os/backend/services/langgraph_engine.py @@ -1,35 +1,135 @@ import asyncio import time -from typing import Dict, Any +from typing import Dict, Any, AsyncGenerator +from tradingagents.graph.trading_graph import TradingAgentsGraph +from tradingagents.graph.scanner_graph import ScannerGraph +from tradingagents.default_config import DEFAULT_CONFIG class LangGraphEngine: - """Orchestrates LangGraph pipeline executions for the AgentOS API.""" + """Orchestrates LangGraph pipeline executions and streams events.""" def __init__(self): - # This is where you would import and setup your LangGraph workflows - # e.g., from tradingagents.graph.setup import setup_trading_graph - pass + self.config = DEFAULT_CONFIG.copy() + # In-memory store to keep track of running tasks if needed + self.active_runs = {} - async def run_scan(self, run_id: str, params: Dict[str, Any]): - print(f"Engine: Starting SCAN {run_id} with params {params}") - # Placeholder for actual scanner graph execution - await asyncio.sleep(15) - print(f"Engine: SCAN {run_id} completed") + async def run_scan(self, run_id: str, params: Dict[str, Any]) -> AsyncGenerator[Dict[str, Any], None]: + """Run the 3-phase macro scanner and stream events.""" + date = params.get("date", time.strftime("%Y-%m-%d")) + + # Initialize ScannerGraph + # Note: ScannerGraph in TradingAgents seems to take date and config + scanner = ScannerGraph(date=date, config=self.config) + + print(f"Engine: Starting SCAN {run_id} for date {date}") + + # Initial state for scanner + # Based on tradingagents/graph/scanner_graph.py + initial_state = { + "date": date, + "geopolitical_report": "", + "market_movers_report": "", + "sector_report": "", + "industry_deep_dive_report": "", + "macro_synthesis_report": "", + "top_10_watchlist": [] + } - async def run_pipeline(self, run_id: str, params: Dict[str, Any]): - print(f"Engine: Starting PIPELINE {run_id} with params {params}") - # Placeholder for actual analysis pipeline execution - await asyncio.sleep(20) - print(f"Engine: PIPELINE {run_id} completed") + async for event in scanner.graph.astream_events(initial_state, version="v2"): + mapped_event = self._map_langgraph_event(event) + if mapped_event: + yield mapped_event - async def run_portfolio(self, run_id: str, params: Dict[str, Any]): - print(f"Engine: Starting PORTFOLIO rebalance {run_id} with params {params}") - # Placeholder for actual portfolio manager graph execution - await asyncio.sleep(10) - print(f"Engine: PORTFOLIO {run_id} completed") + async def run_pipeline(self, run_id: str, params: Dict[str, Any]) -> AsyncGenerator[Dict[str, Any], None]: + """Run per-ticker analysis pipeline and stream events.""" + ticker = params.get("ticker", "AAPL") + date = params.get("date", time.strftime("%Y-%m-%d")) + analysts = params.get("analysts", ["market", "news", "fundamentals"]) + + print(f"Engine: Starting PIPELINE {run_id} for {ticker} on {date}") + + # Initialize TradingAgentsGraph + graph_wrapper = TradingAgentsGraph( + selected_analysts=analysts, + config=self.config, + debug=True + ) + + initial_state = graph_wrapper.propagator.create_initial_state(ticker, date) + # We don't use propagator.get_graph_args() here because we want to stream events directly + + async for event in graph_wrapper.graph.astream_events(initial_state, version="v2"): + mapped_event = self._map_langgraph_event(event) + if mapped_event: + yield mapped_event - async def run_auto(self, run_id: str, params: Dict[str, Any]): - print(f"Engine: Starting AUTO {run_id} with params {params}") - # Placeholder for full automated trading cycle - await asyncio.sleep(30) - print(f"Engine: AUTO {run_id} completed") + def _map_langgraph_event(self, event: Dict[str, Any]) -> Dict[str, Any] | None: + """Map LangGraph v2 events to AgentOS frontend contract.""" + kind = event["event"] + name = event["name"] + tags = event.get("tags", []) + + # Try to extract node name from tags or metadata + node_name = name + for tag in tags: + if tag.startswith("graph:node:"): + node_name = tag.split(":", 2)[-1] + + # Filter for relevant events + if kind == "on_chat_model_start": + return { + "id": event["run_id"], + "node_id": node_name, + "parent_node_id": "start", # Simplified for now + "type": "thought", + "agent": node_name.upper(), + "message": f"Thinking...", + "metrics": { + "model": event["data"].get("invocation_params", {}).get("model_name", "unknown"), + } + } + + elif kind == "on_tool_start": + return { + "id": event["run_id"], + "node_id": f"tool_{name}", + "parent_node_id": node_name, + "type": "tool", + "agent": node_name.upper(), + "message": f"> Tool Call: {name}", + "metrics": {} + } + + elif kind == "on_chat_model_end": + output = event["data"].get("output") + usage = {} + model = "unknown" + if hasattr(output, "usage_metadata") and output.usage_metadata: + usage = output.usage_metadata + if hasattr(output, "response_metadata") and output.response_metadata: + model = output.response_metadata.get("model_name", "unknown") + + return { + "id": f"{event['run_id']}_end", + "node_id": node_name, + "type": "result", + "agent": node_name.upper(), + "message": "Action determined.", + "metrics": { + "model": model, + "tokens_in": usage.get("input_tokens", 0), + "tokens_out": usage.get("output_tokens", 0), + # "latency_ms": ... # calculated in frontend or here if we track start + } + } + + return None + + # Sync versions for BackgroundTasks (if we still want to use them) + async def run_scan_background(self, run_id: str, params: Dict[str, Any]): + async for _ in self.run_scan(run_id, params): + pass + + async def run_pipeline_background(self, run_id: str, params: Dict[str, Any]): + async for _ in self.run_pipeline(run_id, params): + pass diff --git a/agent_os/backend/store.py b/agent_os/backend/store.py new file mode 100644 index 00000000..8c8fc3a6 --- /dev/null +++ b/agent_os/backend/store.py @@ -0,0 +1,4 @@ +from typing import Dict, Any + +# In-memory store for demo (should be replaced by Redis/DB for persistence) +runs: Dict[str, Dict[str, Any]] = {} diff --git a/agent_os/frontend/index.html b/agent_os/frontend/index.html new file mode 100644 index 00000000..e39096af --- /dev/null +++ b/agent_os/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + AgentOS | Observability Command Center + + +
+ + + diff --git a/agent_os/frontend/package.json b/agent_os/frontend/package.json new file mode 100644 index 00000000..bcfd7001 --- /dev/null +++ b/agent_os/frontend/package.json @@ -0,0 +1,38 @@ +{ + "name": "agent-os-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@chakra-ui/react": "^2.8.2", + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "axios": "^1.6.2", + "framer-motion": "^10.16.5", + "lucide-react": "^0.294.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "reactflow": "^11.10.1" + }, + "devDependencies": { + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@typescript-eslint/eslint-plugin": "^6.10.0", + "@typescript-eslint/parser": "^6.10.0", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.16", + "eslint": "^8.53.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.4", + "postcss": "^8.4.31", + "tailwindcss": "^3.3.5", + "typescript": "^5.2.2", + "vite": "^5.0.0" + } +} diff --git a/agent_os/frontend/postcss.config.js b/agent_os/frontend/postcss.config.js new file mode 100644 index 00000000..2e7af2b7 --- /dev/null +++ b/agent_os/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/agent_os/frontend/src/App.tsx b/agent_os/frontend/src/App.tsx new file mode 100644 index 00000000..872d81e5 --- /dev/null +++ b/agent_os/frontend/src/App.tsx @@ -0,0 +1,13 @@ +import { ChakraProvider } from '@chakra-ui/react'; +import theme from './theme'; +import { Dashboard } from './Dashboard'; + +function App() { + return ( + + + + ); +} + +export default App; diff --git a/agent_os/frontend/src/Dashboard.tsx b/agent_os/frontend/src/Dashboard.tsx new file mode 100644 index 00000000..08c52334 --- /dev/null +++ b/agent_os/frontend/src/Dashboard.tsx @@ -0,0 +1,139 @@ +import React, { useState } from 'react'; +import { + Box, + Flex, + VStack, + HStack, + Text, + IconButton, + Button, + useDisclosure, + Drawer, + DrawerOverlay, + DrawerContent, + DrawerHeader, + DrawerBody, + Divider, + Tag, +} from '@chakra-ui/react'; +import { LayoutDashboard, Wallet, Settings, Play, Terminal as TerminalIcon, ChevronRight } from 'lucide-react'; +import { MetricHeader } from './components/MetricHeader'; +import { AgentGraph } from './components/AgentGraph'; +import { useAgentStream } from './hooks/useAgentStream'; +import axios from 'axios'; + +const API_BASE = 'http://localhost:8000/api'; + +export const Dashboard: React.FC = () => { + const [activeRunId, setActiveRunId] = useState(null); + const { events, status, clearEvents } = useAgentStream(activeRunId); + const { isOpen, onOpen, onClose } = useDisclosure(); + const [selectedNode, setSelectedNode] = useState(null); + + const startRun = async (type: string) => { + try { + clearEvents(); + const res = await axios.post(`${API_BASE}/run/${type}`); + setActiveRunId(res.data.run_id); + } catch (err) { + console.error("Failed to start run:", err); + } + }; + + return ( + + {/* Sidebar */} + + A + } variant="ghost" color="cyan.400" _hover={{ bg: "whiteAlpha.100" }} /> + } variant="ghost" color="whiteAlpha.600" _hover={{ bg: "whiteAlpha.100" }} /> + } variant="ghost" color="whiteAlpha.600" _hover={{ bg: "whiteAlpha.100" }} /> + + + {/* Main Content */} + + {/* Top Metric Header */} + + + {/* Dashboard Body */} + + {/* Left Side: Graph Area */} + + + + {/* Floating Control Panel */} + + + + + {status.toUpperCase()} + + + + + {/* Right Side: Live Terminal */} + + + + Live Terminal + + + + {events.map((evt, i) => ( + + + [{evt.timestamp}] + + {evt.agent} + + + {evt.message} + + {evt.metrics && ( + + tokens: {evt.metrics.tokens_in}/{evt.metrics.tokens_out} + time: {evt.metrics.latency_ms}ms + + )} + + ))} + {events.length === 0 && ( + + + Awaiting agent activation... + + )} + + + + + + {/* Node Inspector Drawer */} + + + + + Node Inspector: {selectedNode?.agent} + + + {/* Inspector content would go here */} + Detailed metrics and raw JSON responses for the selected node. + + + + + ); +}; diff --git a/agent_os/frontend/src/components/AgentGraph.tsx b/agent_os/frontend/src/components/AgentGraph.tsx new file mode 100644 index 00000000..8df5dfbd --- /dev/null +++ b/agent_os/frontend/src/components/AgentGraph.tsx @@ -0,0 +1,158 @@ +import React, { useMemo, useEffect } from 'react'; +import ReactFlow, { + Background, + Controls, + Node, + Edge, + Handle, + Position, + NodeProps, +} from 'reactflow'; +import 'reactflow/dist/style.css'; +import { Box, Text, Flex, Icon, Tooltip, Badge } from '@chakra-ui/react'; +import { Cpu, Tool, Database, TrendingUp, Clock } from 'lucide-react'; +import { AgentEvent } from '../hooks/useAgentStream'; + +// --- Custom Agent Node Component --- +const AgentNode = ({ data }: NodeProps) => { + const getIcon = (agent: string) => { + switch (agent.toUpperCase()) { + case 'ANALYST': return Cpu; + case 'RESEARCHER': return Database; + case 'TRADER': return TrendingUp; + default: return Tool; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'running': return 'cyan.400'; + case 'completed': return 'green.400'; + case 'error': return 'red.400'; + default: return 'whiteAlpha.500'; + } + }; + + return ( + + + + + + + {data.agent} + + + + + + + + {data.metrics?.latency_ms || 0}ms + + {data.metrics?.model && ( + {data.metrics.model} + )} + + + {data.status === 'running' && ( + + + + )} + + + + + ); +}; + +const nodeTypes = { + agentNode: AgentNode, +}; + +interface AgentGraphProps { + events: AgentEvent[]; +} + +export const AgentGraph: React.FC = ({ events }) => { + const { nodes, edges } = useMemo(() => { + const graphNodes: Node[] = []; + const graphEdges: Edge[] = []; + const seenNodes = new Set(); + + events.forEach((evt) => { + if (!evt.node_id) return; + + if (!seenNodes.has(evt.node_id)) { + graphNodes.push({ + id: evt.node_id, + type: 'agentNode', + position: { x: 250, y: graphNodes.length * 150 + 50 }, // Simple vertical layout + data: { + agent: evt.agent, + status: evt.type === 'result' ? 'completed' : 'running', + metrics: evt.metrics + }, + }); + seenNodes.add(evt.node_id); + + if (evt.parent_node_id && evt.parent_node_id !== 'start') { + graphEdges.push({ + id: `e-${evt.parent_node_id}-${evt.node_id}`, + source: evt.parent_node_id, + target: evt.node_id, + animated: true, + style: { stroke: '#4fd1c5' }, + }); + } + } else { + // Update existing node status and metrics + const idx = graphNodes.findIndex(n => n.id === evt.node_id); + if (idx !== -1) { + graphNodes[idx].data = { + ...graphNodes[idx].data, + status: evt.type === 'result' ? 'completed' : 'running', + metrics: evt.metrics || graphNodes[idx].data.metrics + }; + } + } + }); + + return { nodes: graphNodes, edges: graphEdges }; + }, [events]); + + return ( + + + + + + + ); +}; diff --git a/agent_os/frontend/src/components/MetricHeader.tsx b/agent_os/frontend/src/components/MetricHeader.tsx new file mode 100644 index 00000000..63e71943 --- /dev/null +++ b/agent_os/frontend/src/components/MetricHeader.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Box, Flex, Text, Stat, StatLabel, StatNumber, StatHelpText, StatArrow, Badge, Icon } from '@chakra-ui/react'; +import { Activity, ShieldAlert, TrendingUp } from 'lucide-react'; + +export const MetricHeader: React.FC = () => { + return ( + + {/* Metric 1: Sharpe Ratio */} + + + + Sharpe Ratio (30d) + + + 2.42 + High Efficiency + + + + {/* Metric 2: Market Regime */} + + + + Market Regime + + + BULL + Beta: 1.15 + + + + {/* Metric 3: Risk / Drawdown */} + + + + Risk / Drawdown + + + -2.4% + VaR (1d): $4.2k + + + + ); +}; diff --git a/agent_os/frontend/useAgentStream.ts b/agent_os/frontend/src/hooks/useAgentStream.ts similarity index 100% rename from agent_os/frontend/useAgentStream.ts rename to agent_os/frontend/src/hooks/useAgentStream.ts diff --git a/agent_os/frontend/src/index.css b/agent_os/frontend/src/index.css new file mode 100644 index 00000000..6ed5cb25 --- /dev/null +++ b/agent_os/frontend/src/index.css @@ -0,0 +1,14 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: dark; +} + +body { + margin: 0; + padding: 0; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/agent_os/frontend/src/main.tsx b/agent_os/frontend/src/main.tsx new file mode 100644 index 00000000..9aa52ffd --- /dev/null +++ b/agent_os/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/agent_os/frontend/src/theme.ts b/agent_os/frontend/src/theme.ts new file mode 100644 index 00000000..b2205c54 --- /dev/null +++ b/agent_os/frontend/src/theme.ts @@ -0,0 +1,35 @@ +import { extendTheme, type ThemeConfig } from '@chakra-ui/react'; + +const config: ThemeConfig = { + initialColorMode: 'dark', + useSystemColorMode: false, +}; + +const theme = extendTheme({ + config, + colors: { + slate: { + 50: '#f8fafc', + 100: '#f1f5f9', + 200: '#e2e8f0', + 300: '#cbd5e1', + 400: '#94a3b8', + 500: '#64748b', + 600: '#475569', + 700: '#334155', + 800: '#1e293b', + 900: '#0f172a', + 950: '#020617', + }, + }, + styles: { + global: { + body: { + bg: 'slate.950', + color: 'white', + }, + }, + }, +}); + +export default theme; diff --git a/agent_os/frontend/tailwind.config.js b/agent_os/frontend/tailwind.config.js new file mode 100644 index 00000000..dca8ba02 --- /dev/null +++ b/agent_os/frontend/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +}