diff --git a/agent_os/backend/services/langgraph_engine.py b/agent_os/backend/services/langgraph_engine.py index d12e5b54..2f40a804 100644 --- a/agent_os/backend/services/langgraph_engine.py +++ b/agent_os/backend/services/langgraph_engine.py @@ -233,19 +233,35 @@ class LangGraphEngine: Returns the concatenated content of every message so the user can inspect the full prompt that was sent to the LLM. + + Handles several structures observed across LangChain / LangGraph versions: + - flat list of message objects ``[SystemMessage, HumanMessage, ...]`` + - list-of-lists (batched) ``[[SystemMessage, HumanMessage, ...]]`` + - list of plain dicts ``[{"role": "system", "content": "..."}]`` + - tuple wrapper ``([SystemMessage, ...],)`` """ - if not isinstance(messages, list) or not messages: + if not messages: return "" + + # Unwrap single-element tuple / list-of-lists + items: list = messages if isinstance(messages, list) else list(messages) + if items and isinstance(items[0], (list, tuple)): + items = list(items[0]) + parts: list[str] = [] - items = messages - # Handle list-of-lists - if isinstance(items[0], list): - items = items[0] for msg in items: + # LangChain message objects have .content and .type content = getattr(msg, "content", None) - role = getattr(msg, "type", "unknown") + role = getattr(msg, "type", None) + # Plain-dict messages (e.g. {"role": "user", "content": "..."}) + if content is None and isinstance(msg, dict): + content = msg.get("content", "") + role = msg.get("role") or msg.get("type") or "unknown" + if role is None: + role = "unknown" text = str(content) if content is not None else str(msg) parts.append(f"[{role}] {text}") + return "\n\n".join(parts) def _extract_model(self, event: Dict[str, Any]) -> str: @@ -288,13 +304,35 @@ class LangGraphEngine: if kind == "on_chat_model_start": starts[node_name] = time.monotonic() - # Extract the full prompt being sent to the LLM + data = event.get("data") or {} + + # Extract the full prompt being sent to the LLM. + # Try multiple paths observed in different LangChain versions: + # 1. data.messages (most common) + # 2. data.input.messages (newer LangGraph) + # 3. data.input (if it's a list of messages itself) + # 4. data.kwargs.messages (some providers) full_prompt = "" - prompt_snippet = "" - messages = (event.get("data") or {}).get("messages") - if messages: - full_prompt = self._extract_all_messages_content(messages) - prompt_snippet = self._truncate(full_prompt.replace("\n", " ")) + for source in ( + data.get("messages"), + (data.get("input") or {}).get("messages") if isinstance(data.get("input"), dict) else None, + data.get("input") if isinstance(data.get("input"), (list, tuple)) else None, + (data.get("kwargs") or {}).get("messages"), + ): + if source: + full_prompt = self._extract_all_messages_content(source) + if full_prompt: + break + + # If all structured extractions failed, dump a raw preview + if not full_prompt: + raw_dump = str(data)[:_MAX_FULL_LEN] + if raw_dump and raw_dump != "{}": + full_prompt = f"[raw event data] {raw_dump}" + + prompt_snippet = self._truncate( + full_prompt.replace("\n", " "), _MAX_CONTENT_LEN + ) if full_prompt else "" # Remember the full prompt so we can attach it to the result event prompts[node_name] = full_prompt @@ -377,7 +415,16 @@ class LangGraphEngine: usage = output.usage_metadata if hasattr(output, "response_metadata") and output.response_metadata: model = output.response_metadata.get("model_name") or output.response_metadata.get("model", model) + + # Extract the response text – handle both message objects and plain dicts raw = self._extract_content(output) + # If .content was empty or the repr of the whole object, try harder + if not raw or raw.startswith("<") or raw == str(output): + # Some providers wrap in .text or .message + raw = ( + getattr(output, "text", "") + or (output.get("content", "") if isinstance(output, dict) else "") + ) if raw: full_response = raw[:_MAX_FULL_LEN] response_snippet = self._truncate(raw) diff --git a/agent_os/frontend/src/Dashboard.tsx b/agent_os/frontend/src/Dashboard.tsx index 101eac22..c5ba66e2 100644 --- a/agent_os/frontend/src/Dashboard.tsx +++ b/agent_os/frontend/src/Dashboard.tsx @@ -7,6 +7,7 @@ import { Text, IconButton, Button, + Input, useDisclosure, Drawer, DrawerOverlay, @@ -29,15 +30,43 @@ import { TabPanels, Tab, TabPanel, + Tooltip, + Collapse, + useToast, } from '@chakra-ui/react'; -import { LayoutDashboard, Wallet, Settings, Play, Terminal as TerminalIcon, ChevronRight, Eye, Search, BarChart3, Bot } from 'lucide-react'; +import { LayoutDashboard, Wallet, Settings, Terminal as TerminalIcon, ChevronRight, Eye, Search, BarChart3, Bot, ChevronDown, ChevronUp } from 'lucide-react'; import { MetricHeader } from './components/MetricHeader'; import { AgentGraph } from './components/AgentGraph'; +import { PortfolioViewer } from './components/PortfolioViewer'; import { useAgentStream, AgentEvent } from './hooks/useAgentStream'; import axios from 'axios'; const API_BASE = 'http://127.0.0.1:8088/api'; +// ─── Run type definitions with required parameters ──────────────────── +type RunType = 'scan' | 'pipeline' | 'portfolio' | 'auto'; + +interface RunParams { + date: string; + ticker: string; + portfolio_id: string; +} + +const RUN_TYPE_LABELS: Record = { + scan: 'Scan', + pipeline: 'Pipeline', + portfolio: 'Portfolio', + auto: 'Auto', +}; + +/** Which params each run type needs. */ +const REQUIRED_PARAMS: Record = { + scan: ['date'], + pipeline: ['ticker', 'date'], + portfolio: ['date', 'portfolio_id'], + auto: ['date', 'ticker'], +}; + /** Return the colour token for a given event type. */ const eventColor = (type: AgentEvent['type']): string => { switch (type) { @@ -256,12 +285,17 @@ const NodeEventsDetail: React.FC<{ nodeId: string; events: AgentEvent[]; onOpenM ); }; +// ─── Sidebar page type ──────────────────────────────────────────────── +type Page = 'dashboard' | 'portfolio'; + export const Dashboard: React.FC = () => { + const [activePage, setActivePage] = useState('dashboard'); const [activeRunId, setActiveRunId] = useState(null); + const [activeRunType, setActiveRunType] = useState(null); const [isTriggering, setIsTriggering] = useState(false); - const [portfolioId, setPortfolioId] = useState("main_portfolio"); const { events, status, clearEvents } = useAgentStream(activeRunId); const { isOpen, onOpen, onClose } = useDisclosure(); + const toast = useToast(); // Event detail modal state const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure(); @@ -272,6 +306,14 @@ export const Dashboard: React.FC = () => { const [selectedEvent, setSelectedEvent] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(null); + // Parameter inputs + const [showParams, setShowParams] = useState(false); + const [params, setParams] = useState({ + date: new Date().toISOString().split('T')[0], + ticker: 'AAPL', + portfolio_id: 'main_portfolio', + }); + // Auto-scroll the terminal to the bottom as new events arrive const terminalEndRef = useRef(null); @@ -279,21 +321,47 @@ export const Dashboard: React.FC = () => { terminalEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [events.length]); + // Clear activeRunType when run completes + useEffect(() => { + if (status === 'completed' || status === 'error') { + setActiveRunType(null); + } + }, [status]); + const isRunning = isTriggering || status === 'streaming' || status === 'connecting'; - const startRun = async (type: string) => { + const startRun = async (type: RunType) => { if (isRunning) return; + + // Validate required params + const required = REQUIRED_PARAMS[type]; + const missing = required.filter((k) => !params[k]?.trim()); + if (missing.length > 0) { + toast({ + title: `Missing required fields for ${RUN_TYPE_LABELS[type]}`, + description: `Please fill in: ${missing.join(', ')}`, + status: 'warning', + duration: 3000, + isClosable: true, + position: 'top', + }); + setShowParams(true); + return; + } setIsTriggering(true); + setActiveRunType(type); try { clearEvents(); const res = await axios.post(`${API_BASE}/run/${type}`, { - portfolio_id: portfolioId, - date: new Date().toISOString().split('T')[0] + portfolio_id: params.portfolio_id, + date: params.date, + ticker: params.ticker, }); setActiveRunId(res.data.run_id); } catch (err) { console.error("Failed to start run:", err); + setActiveRunType(null); } finally { setIsTriggering(false); } @@ -331,125 +399,196 @@ export const Dashboard: React.FC = () => { {/* Sidebar */} A - } variant="ghost" color="cyan.400" _hover={{ bg: "whiteAlpha.100" }} /> - } variant="ghost" color="whiteAlpha.600" _hover={{ bg: "whiteAlpha.100" }} /> + + } + variant="ghost" + color={activePage === 'dashboard' ? 'cyan.400' : 'whiteAlpha.600'} + bg={activePage === 'dashboard' ? 'whiteAlpha.100' : 'transparent'} + _hover={{ bg: "whiteAlpha.100" }} + onClick={() => setActivePage('dashboard')} + /> + + + } + variant="ghost" + color={activePage === 'portfolio' ? 'cyan.400' : 'whiteAlpha.600'} + bg={activePage === 'portfolio' ? 'whiteAlpha.100' : 'transparent'} + _hover={{ bg: "whiteAlpha.100" }} + onClick={() => setActivePage('portfolio')} + /> + } variant="ghost" color="whiteAlpha.600" _hover={{ bg: "whiteAlpha.100" }} /> - {/* Main Content */} - - {/* Top Metric Header */} - + {/* ─── Portfolio Page ────────────────────────────────────────── */} + {activePage === 'portfolio' && ( + + + + )} - {/* Dashboard Body */} - - {/* Left Side: Graph Area */} - - - - {/* Floating Control Panel */} - - - - - - - - {status.toUpperCase()} - - - + {/* ─── Dashboard Page ────────────────────────────────────────── */} + {activePage === 'dashboard' && ( + + {/* Top Metric Header */} + - {/* Right Side: Live Terminal */} - - - - Live Terminal - {events.length} events - - - - {events.map((evt) => ( - openEventDetail(evt)} - transition="background 0.15s" - > - - [{evt.timestamp}] - {eventLabel(evt.type)} - - {evt.agent} - - - {eventSummary(evt)} - - - - ))} - {events.length === 0 && ( - - - Awaiting agent activation... - - )} -
+ {/* Dashboard Body */} + + {/* Left Side: Graph Area */} + + + + {/* Floating Control Panel */} + + {/* Run buttons row */} + + {(['scan', 'pipeline', 'portfolio', 'auto'] as RunType[]).map((type) => { + const isThisRunning = isRunning && activeRunType === type; + const isOtherRunning = isRunning && activeRunType !== type; + const icons: Record = { + scan: , + pipeline: , + portfolio: , + auto: , + }; + const colors: Record = { + scan: 'cyan', + pipeline: 'blue', + portfolio: 'purple', + auto: 'green', + }; + return ( + + ); + })} + + + {status.toUpperCase()} + + : } + size="xs" + variant="ghost" + color="whiteAlpha.600" + onClick={() => setShowParams(!showParams)} + /> + + + {/* Collapsible parameter inputs */} + + + + + Date: + setParams((p) => ({ ...p, date: e.target.value }))} + /> + + + Ticker: + setParams((p) => ({ ...p, ticker: e.target.value.toUpperCase() }))} + /> + + + Portfolio: + setParams((p) => ({ ...p, portfolio_id: e.target.value }))} + /> + + + Required: Scan → date · Pipeline → ticker, date · Portfolio → date, portfolio · Auto → date, ticker + + + + + - + + {/* Right Side: Live Terminal */} + + + + Live Terminal + {events.length} events + + + + {events.map((evt) => ( + openEventDetail(evt)} + transition="background 0.15s" + > + + [{evt.timestamp}] + {eventLabel(evt.type)} + + {evt.agent} + + + {eventSummary(evt)} + + + + ))} + {events.length === 0 && ( + + + Awaiting agent activation... + + )} +
+ + + - + )} {/* Unified Inspector Drawer (single event or all node events) */} diff --git a/agent_os/frontend/src/components/PortfolioViewer.tsx b/agent_os/frontend/src/components/PortfolioViewer.tsx new file mode 100644 index 00000000..8af38609 --- /dev/null +++ b/agent_os/frontend/src/components/PortfolioViewer.tsx @@ -0,0 +1,280 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Box, + Flex, + VStack, + HStack, + Text, + Badge, + Code, + Spinner, + Select, + Table, + Thead, + Tbody, + Tr, + Th, + Td, + Tabs, + TabList, + TabPanels, + Tab, + TabPanel, + Icon, +} from '@chakra-ui/react'; +import { Wallet, ArrowUpRight, ArrowDownRight, RefreshCw } from 'lucide-react'; +import axios from 'axios'; + +const API_BASE = 'http://127.0.0.1:8088/api'; + +interface Holding { + ticker: string; + quantity: number; + avg_cost: number; + current_price?: number; + market_value?: number; + unrealized_pnl?: number; + sector?: string; + [key: string]: unknown; +} + +interface Trade { + id?: string; + ticker: string; + action: string; + quantity: number; + price: number; + executed_at?: string; + rationale?: string; + [key: string]: unknown; +} + +interface PortfolioInfo { + id: string; + name?: string; + cash_balance?: number; + [key: string]: unknown; +} + +interface PortfolioState { + portfolio: PortfolioInfo; + snapshot: Record | null; + holdings: Holding[]; + recent_trades: Trade[]; +} + +interface PortfolioViewerProps { + defaultPortfolioId?: string; +} + +export const PortfolioViewer: React.FC = ({ defaultPortfolioId = 'main_portfolio' }) => { + const [portfolios, setPortfolios] = useState([]); + const [selectedId, setSelectedId] = useState(defaultPortfolioId); + const [state, setState] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Fetch portfolio list + useEffect(() => { + const fetchList = async () => { + try { + const res = await axios.get(`${API_BASE}/portfolios/`); + const list = res.data as PortfolioInfo[]; + setPortfolios(list); + if (list.length > 0 && !list.find((p) => p.id === selectedId)) { + setSelectedId(list[0].id); + } + } catch { + // Might fail if no DB — use fallback + setPortfolios([{ id: defaultPortfolioId, name: defaultPortfolioId }]); + } + }; + fetchList(); + }, [defaultPortfolioId, selectedId]); + + // Fetch portfolio state when selection changes + const fetchState = useCallback(async () => { + if (!selectedId) return; + setLoading(true); + setError(null); + try { + const res = await axios.get(`${API_BASE}/portfolios/${selectedId}/latest`); + setState(res.data as PortfolioState); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to load portfolio'; + setError(msg); + setState(null); + } finally { + setLoading(false); + } + }, [selectedId]); + + useEffect(() => { + fetchState(); + }, [fetchState]); + + return ( + + {/* Header */} + + + Portfolio Viewer + + + + + + + + + {/* Body */} + {loading && ( + + )} + + {error && ( + + {error} + Make sure the backend is running and the portfolio exists. + + )} + + {!loading && !error && state && ( + + + Holdings ({state.holdings.length}) + Trade History ({state.recent_trades.length}) + Summary + + + + {/* Holdings */} + + {state.holdings.length === 0 ? ( + No holdings found. + ) : ( + + + + + + + + + + + + + + {state.holdings.map((h, i) => { + const pnl = h.unrealized_pnl ?? 0; + return ( + + + + + + + + + ); + })} + +
TickerQtyAvg CostMkt ValueP&LSector
{h.ticker}{h.quantity}${(h.avg_cost ?? 0).toFixed(2)}${(h.market_value ?? 0).toFixed(2)}= 0 ? 'green.400' : 'red.400'}> + + = 0 ? ArrowUpRight : ArrowDownRight} boxSize={3} /> + ${Math.abs(pnl).toFixed(2)} + + {h.sector || '—'}
+
+ )} +
+ + {/* Trade History */} + + {state.recent_trades.length === 0 ? ( + No trades recorded yet. + ) : ( + + {state.recent_trades.map((t, i) => ( + + + + {t.action?.toUpperCase()} + + {t.ticker} + {t.quantity} @ ${(t.price ?? 0).toFixed(2)} + + + {t.executed_at || '—'} + {t.rationale && ( + {t.rationale} + )} + + + ))} + + )} + + + {/* Summary */} + + + + Portfolio ID: + {state.portfolio.id} + + {state.portfolio.cash_balance != null && ( + + Cash Balance: + ${state.portfolio.cash_balance.toFixed(2)} + + )} + {state.snapshot && ( + + Latest Snapshot + + + {JSON.stringify(state.snapshot, null, 2)} + + + + )} + + +
+
+ )} + + {!loading && !error && !state && ( + + + Select a portfolio to view + + )} +
+ ); +};