Merge PR #369: Standardize agent activity events and update timeline UI

This commit is contained in:
ajaysi
2026-03-03 18:25:05 +05:30
5 changed files with 2739 additions and 90 deletions

View File

@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Union
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -9,6 +10,73 @@ from sqlalchemy.orm import Session
from models.agent_activity_models import AgentAlert, AgentApprovalRequest, AgentEvent, AgentRun from models.agent_activity_models import AgentAlert, AgentApprovalRequest, AgentEvent, AgentRun
@dataclass
class AgentEventPayload:
"""Shared schema for agent activity event payloads."""
phase: Optional[str] = None
step: Optional[str] = None
tool_name: Optional[str] = None
progress_percent: Optional[float] = None
input_summary: Optional[str] = None
output_summary: Optional[str] = None
decision_reason: Optional[str] = None
evidence_refs: List[str] = field(default_factory=list)
safe_debug: bool = True
metadata: Dict[str, Any] = field(default_factory=dict)
def build_agent_event_payload(
*,
phase: Optional[str] = None,
step: Optional[str] = None,
tool_name: Optional[str] = None,
progress_percent: Optional[float] = None,
input_summary: Optional[str] = None,
output_summary: Optional[str] = None,
decision_reason: Optional[str] = None,
evidence_refs: Optional[List[str]] = None,
safe_debug: bool = True,
metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
return asdict(
AgentEventPayload(
phase=phase,
step=step,
tool_name=tool_name,
progress_percent=progress_percent,
input_summary=input_summary,
output_summary=output_summary,
decision_reason=decision_reason,
evidence_refs=list(evidence_refs or []),
safe_debug=bool(safe_debug),
metadata=dict(metadata or {}),
)
)
def _normalize_event_payload(payload: Optional[Union[Dict[str, Any], AgentEventPayload]]) -> Dict[str, Any]:
if payload is None:
return build_agent_event_payload()
if isinstance(payload, AgentEventPayload):
return asdict(payload)
if not isinstance(payload, dict):
return build_agent_event_payload(output_summary=str(payload)[:2000], safe_debug=False)
return build_agent_event_payload(
phase=payload.get("phase"),
step=payload.get("step"),
tool_name=payload.get("tool_name"),
progress_percent=payload.get("progress_percent"),
input_summary=payload.get("input_summary"),
output_summary=payload.get("output_summary"),
decision_reason=payload.get("decision_reason"),
evidence_refs=payload.get("evidence_refs") if isinstance(payload.get("evidence_refs"), list) else [],
safe_debug=bool(payload.get("safe_debug", True)),
metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {},
)
class AgentActivityService: class AgentActivityService:
def __init__(self, db: Session, user_id: str): def __init__(self, db: Session, user_id: str):
self.db = db self.db = db
@@ -51,10 +119,11 @@ class AgentActivityService:
event_type: str, event_type: str,
severity: str = "info", severity: str = "info",
message: Optional[str] = None, message: Optional[str] = None,
payload: Optional[Dict[str, Any]] = None, payload: Optional[Union[Dict[str, Any], AgentEventPayload]] = None,
run_id: Optional[int] = None, run_id: Optional[int] = None,
agent_type: Optional[str] = None, agent_type: Optional[str] = None,
) -> AgentEvent: ) -> AgentEvent:
normalized_payload = _normalize_event_payload(payload)
evt = AgentEvent( evt = AgentEvent(
run_id=run_id, run_id=run_id,
user_id=self.user_id, user_id=self.user_id,
@@ -62,7 +131,7 @@ class AgentActivityService:
event_type=event_type, event_type=event_type,
severity=severity, severity=severity,
message=message, message=message,
payload=payload, payload=normalized_payload,
created_at=datetime.utcnow(), created_at=datetime.utcnow(),
) )
self.db.add(evt) self.db.add(evt)

View File

@@ -38,7 +38,7 @@ from utils.logger_utils import get_service_logger
from services.database import get_session_for_user from services.database import get_session_for_user
from services.intelligence.monitoring.semantic_dashboard import RealTimeSemanticMonitor from services.intelligence.monitoring.semantic_dashboard import RealTimeSemanticMonitor
from services.intelligence.agents.safety_framework import get_safety_framework from services.intelligence.agents.safety_framework import get_safety_framework
from services.agent_activity_service import AgentActivityService from services.agent_activity_service import AgentActivityService, build_agent_event_payload
from services.intelligence.agents.agent_usage_tracking import track_agent_usage_sync from services.intelligence.agents.agent_usage_tracking import track_agent_usage_sync
import time import time
@@ -504,7 +504,7 @@ class BaseALwrityAgent(ABC):
event_type="plan", event_type="plan",
severity="info", severity="info",
message=(prompt[:2000] if prompt else None), message=(prompt[:2000] if prompt else None),
payload={"kind": "prompt"}, payload=build_agent_event_payload(phase="planning", step="run_started", tool_name="agent_run", progress_percent=0, input_summary=prompt[:250], output_summary="Agent run initialized", decision_reason="Received run prompt", safe_debug=False, metadata={"kind": "prompt"}),
run_id=run_record.id, run_id=run_record.id,
agent_type=self.agent_type, agent_type=self.agent_type,
) )
@@ -531,7 +531,7 @@ class BaseALwrityAgent(ABC):
event_type="final_summary", event_type="final_summary",
severity="info", severity="info",
message=(str(result)[:2000] if result is not None else None), message=(str(result)[:2000] if result is not None else None),
payload={"kind": "result"}, payload=build_agent_event_payload(phase="execution", step="run_completed", tool_name="agent_run", progress_percent=100, output_summary=str(result)[:400] if result is not None else "No output", decision_reason="Run completed", safe_debug=True, metadata={"kind": "result"}),
run_id=run_record.id, run_id=run_record.id,
agent_type=self.agent_type, agent_type=self.agent_type,
) )
@@ -545,7 +545,7 @@ class BaseALwrityAgent(ABC):
event_type="error", event_type="error",
severity="error", severity="error",
message=str(e)[:2000], message=str(e)[:2000],
payload={"kind": "exception"}, payload=build_agent_event_payload(phase="execution", step="run_error", tool_name="agent_runtime", output_summary=str(e)[:400], decision_reason="Unhandled exception during run", safe_debug=False, metadata={"kind": "exception"}),
run_id=run_record.id, run_id=run_record.id,
agent_type=self.agent_type, agent_type=self.agent_type,
) )
@@ -591,7 +591,7 @@ class BaseALwrityAgent(ABC):
event_type="plan", event_type="plan",
severity="info", severity="info",
message=f"{action.action_type} -> {action.target_resource}", message=f"{action.action_type} -> {action.target_resource}",
payload={"action": asdict(action)}, payload=build_agent_event_payload(phase="planning", step="action_received", tool_name=action.action_type, progress_percent=5, input_summary=f"target={action.target_resource}", output_summary="Action accepted for execution", decision_reason="Start run lifecycle", safe_debug=True, metadata={"action": asdict(action)}),
run_id=run_record.id, run_id=run_record.id,
agent_type=self.agent_type, agent_type=self.agent_type,
) )
@@ -606,7 +606,7 @@ class BaseALwrityAgent(ABC):
event_type="decision", event_type="decision",
severity="warning", severity="warning",
message="Action failed safety validation", message="Action failed safety validation",
payload={"action_id": action.action_id, "action_type": action.action_type}, payload=build_agent_event_payload(phase="validation", step="safety_blocked", tool_name="safety_framework", progress_percent=10, input_summary=action.action_type, output_summary="Action blocked by safety validation", decision_reason="Safety framework rejected action", safe_debug=True, metadata={"action_id": action.action_id, "action_type": action.action_type}),
run_id=run_record.id, run_id=run_record.id,
agent_type=self.agent_type, agent_type=self.agent_type,
) )
@@ -646,7 +646,7 @@ class BaseALwrityAgent(ABC):
event_type="decision", event_type="decision",
severity="info", severity="info",
message="Action requires approval", message="Action requires approval",
payload={"approval_id": req.id, "action_id": action.action_id}, payload=build_agent_event_payload(phase="approval", step="awaiting_user_decision", tool_name=action.action_type, progress_percent=20, input_summary=action.target_resource, output_summary="Approval request created", decision_reason="Action requires human approval", safe_debug=True, metadata={"approval_id": req.id, "action_id": action.action_id}),
run_id=run_record.id, run_id=run_record.id,
agent_type=self.agent_type, agent_type=self.agent_type,
) )
@@ -671,7 +671,7 @@ class BaseALwrityAgent(ABC):
event_type="progress", event_type="progress",
severity="info", severity="info",
message="Rollback checkpoint created", message="Rollback checkpoint created",
payload={"checkpoint_id": checkpoint_id}, payload=build_agent_event_payload(phase="safety", step="checkpoint_created", tool_name="rollback_manager", progress_percent=35, output_summary="Rollback checkpoint created", decision_reason="Prepare rollback safety net", safe_debug=True, metadata={"checkpoint_id": checkpoint_id}),
run_id=run_record.id, run_id=run_record.id,
agent_type=self.agent_type, agent_type=self.agent_type,
) )
@@ -682,7 +682,7 @@ class BaseALwrityAgent(ABC):
event_type="warning", event_type="warning",
severity="warning", severity="warning",
message=str(e)[:2000], message=str(e)[:2000],
payload={"checkpoint": "failed"}, payload=build_agent_event_payload(phase="safety", step="checkpoint_failed", tool_name="rollback_manager", progress_percent=30, output_summary="Checkpoint creation failed", decision_reason="Proceeding without checkpoint", safe_debug=False, metadata={"checkpoint": "failed"}),
run_id=run_record.id, run_id=run_record.id,
agent_type=self.agent_type, agent_type=self.agent_type,
) )
@@ -717,7 +717,7 @@ class BaseALwrityAgent(ABC):
event_type="final_summary", event_type="final_summary",
severity="info", severity="info",
message=str(result)[:2000] if result is not None else None, message=str(result)[:2000] if result is not None else None,
payload={"action_id": action.action_id}, payload=build_agent_event_payload(phase="execution", step="completed", tool_name=action.action_type, progress_percent=100, output_summary=str(result)[:400] if result is not None else "No output", decision_reason="Action execution completed", safe_debug=True, metadata={"action_id": action.action_id}),
run_id=run_record.id, run_id=run_record.id,
agent_type=self.agent_type, agent_type=self.agent_type,
) )
@@ -768,7 +768,7 @@ class BaseALwrityAgent(ABC):
event_type="error", event_type="error",
severity="error", severity="error",
message=str(e)[:2000], message=str(e)[:2000],
payload={"action_id": action.action_id, "checkpoint_id": checkpoint_id}, payload=build_agent_event_payload(phase="execution", step="failed", tool_name=action.action_type, progress_percent=100, output_summary=str(e)[:400], decision_reason="Exception during action execution", safe_debug=False, metadata={"action_id": action.action_id, "checkpoint_id": checkpoint_id}),
run_id=run_record.id, run_id=run_record.id,
agent_type=self.agent_type, agent_type=self.agent_type,
) )

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
from models.daily_workflow_models import DailyWorkflowPlan, DailyWorkflowTask from models.daily_workflow_models import DailyWorkflowPlan, DailyWorkflowTask
from models.agent_activity_models import AgentAlert from models.agent_activity_models import AgentAlert
from services.agent_activity_service import AgentActivityService from services.agent_activity_service import AgentActivityService, build_agent_event_payload
from services.llm_providers.main_text_generation import llm_text_gen from services.llm_providers.main_text_generation import llm_text_gen
from loguru import logger from loguru import logger
@@ -430,7 +430,7 @@ async def generate_agent_enhanced_plan(db: Session, user_id: str, date: str) ->
event_type="plan", event_type="plan",
severity="info", severity="info",
message="Building grounded daily workflow plan", message="Building grounded daily workflow plan",
payload={"grounding": grounding}, payload=build_agent_event_payload(phase="planning", step="build_grounded_plan", tool_name="llm_text_gen", progress_percent=10, input_summary="Grounding data assembled from onboarding + alerts", output_summary="Preparing daily workflow generation", decision_reason="Need context-aware workflow", evidence_refs=["onboarding_data","recent_agent_alerts"], safe_debug=True, metadata={"grounding": grounding}),
run_id=run.id, run_id=run.id,
agent_type="TodayWorkflowGenerator", agent_type="TodayWorkflowGenerator",
) )
@@ -449,7 +449,7 @@ async def generate_agent_enhanced_plan(db: Session, user_id: str, date: str) ->
event_type="warning", event_type="warning",
severity="warning", severity="warning",
message=str(e)[:2000], message=str(e)[:2000],
payload={"fallback": True}, payload=build_agent_event_payload(phase="generation", step="llm_failed_fallback", tool_name="llm_text_gen", progress_percent=70, output_summary="LLM generation failed, using fallback tasks", decision_reason="Exception during workflow generation", safe_debug=False, metadata={"fallback": True}),
run_id=run.id, run_id=run.id,
agent_type="TodayWorkflowGenerator", agent_type="TodayWorkflowGenerator",
) )
@@ -467,7 +467,7 @@ async def generate_agent_enhanced_plan(db: Session, user_id: str, date: str) ->
event_type="final_summary", event_type="final_summary",
severity="info", severity="info",
message="Daily workflow plan generated", message="Daily workflow plan generated",
payload={"date": date, "task_count": len(result.get("tasks", []))}, payload=build_agent_event_payload(phase="generation", step="workflow_generated", tool_name="llm_text_gen", progress_percent=100, output_summary=f"Generated {len(result.get('tasks', []))} tasks", decision_reason="Workflow assembled successfully", evidence_refs=[date], safe_debug=True, metadata={"date": date, "task_count": len(result.get("tasks", []))}),
run_id=run.id, run_id=run.id,
agent_type="TodayWorkflowGenerator", agent_type="TodayWorkflowGenerator",
) )

View File

@@ -3,106 +3,161 @@ import {
Box, Box,
Paper, Paper,
Typography, Typography,
Avatar,
Chip, Chip,
List, List,
ListItem, ListItem,
ListItemAvatar,
ListItemText,
Divider, Divider,
IconButton, IconButton,
Tooltip Tooltip,
CircularProgress,
Accordion,
AccordionSummary,
AccordionDetails,
Stack,
} from '@mui/material'; } from '@mui/material';
import { import {
Psychology as StrategyIcon, Refresh as RefreshIcon,
Article as ContentIcon, ExpandMore as ExpandMoreIcon,
Search as SeoIcon,
Campaign as SocialIcon,
CompareArrows as CompetitorIcon,
Refresh as RefreshIcon
} from '@mui/icons-material'; } from '@mui/icons-material';
import { Link as RouterLink } from 'react-router-dom'; import { apiClient } from '../../../api/client';
import { useAgentHuddleFeed } from '../../../hooks/useAgentHuddleFeed';
const ICON_BY_AGENT: Record<string, React.ElementType> = { type EventPayload = {
strategy: StrategyIcon, phase?: string | null;
content: ContentIcon, step?: string | null;
seo: SeoIcon, tool_name?: string | null;
social: SocialIcon, progress_percent?: number | null;
competitor: CompetitorIcon, input_summary?: string | null;
output_summary?: string | null;
decision_reason?: string | null;
evidence_refs?: string[] | null;
safe_debug?: boolean;
metadata?: Record<string, unknown>;
};
type TeamActivityEvent = {
id: number;
event_type: string;
severity: string;
message?: string | null;
created_at?: string | null;
payload?: EventPayload | null;
};
type AgentRun = {
id: number;
agent_type: string;
status: string;
started_at?: string | null;
}; };
const TeamHuddleWidget: React.FC = () => { const TeamHuddleWidget: React.FC = () => {
const { runs, connectionMode, lastHeartbeatAt } = useAgentHuddleFeed(); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [timeline, setTimeline] = React.useState<Array<{ run: AgentRun; events: TeamActivityEvent[] }>>([]);
// Create rows for the widget from the feed runs const loadTimeline = React.useCallback(async () => {
// Note: events are not directly used in the simple widget view, but available if needed setLoading(true);
const rows = React.useMemo(() => { setError(null);
return runs.slice(0, 5).map((run) => { try {
const agentType = String(run.agent_type || 'strategy'); const runsResp = await apiClient.get('/api/agents/runs', { params: { limit: 5 } });
// Simple heuristic for icon mapping const runs: AgentRun[] = runsResp?.data?.data?.runs || [];
let IconComponent: React.ElementType = StrategyIcon;
for (const key in ICON_BY_AGENT) {
if (agentType.toLowerCase().includes(key)) {
IconComponent = ICON_BY_AGENT[key];
break;
}
}
const status = run.status === 'running' ? 'thinking' : run.success === false ? 'offline' : 'active'; const eventResponses = await Promise.all(
return { runs.slice(0, 3).map(async (run) => {
id: run.id, const eventsResp = await apiClient.get(`/api/agents/runs/${run.id}/events`, { params: { limit: 25 } });
name: agentType.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()), return {
role: run.status || 'active', run,
status, events: (eventsResp?.data?.data?.events || []) as TeamActivityEvent[],
current_activity: run.result_summary || run.error_message || 'Awaiting next update', };
icon: IconComponent, }),
}; );
});
}, [runs]); setTimeline(eventResponses);
} catch (e: any) {
setError(e?.message || 'Failed to load team activity');
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
loadTimeline();
}, [loadTimeline]);
return ( return (
<Paper elevation={0} sx={{ p: 2, borderRadius: 3, border: '1px solid', borderColor: 'divider', height: '100%', background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)' }}> <Paper elevation={0} sx={{ p: 2, borderRadius: 3, border: '1px solid', borderColor: 'divider', height: '100%' }}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}> <Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Box display="flex" alignItems="center" gap={1}> <Box display="flex" alignItems="center" gap={1}>
<Typography variant="h6" fontWeight={700} color="text.primary">Team Huddle</Typography> <Typography variant="h6" fontWeight={700}>Team Activity</Typography>
<Chip label={connectionMode === 'sse' ? 'Live' : connectionMode === 'polling' ? 'Polling' : 'Connecting'} size="small" color={connectionMode === 'sse' ? 'success' : 'warning'} sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700 }} /> <Chip label="Live" size="small" color="success" sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700 }} />
</Box> </Box>
<Tooltip title={lastHeartbeatAt ? `Heartbeat ${new Date(lastHeartbeatAt).toLocaleTimeString()}` : 'Waiting for heartbeat'}> <Tooltip title="Refresh Team Activity">
<IconButton size="small"><RefreshIcon fontSize="small" /></IconButton> <IconButton size="small" onClick={loadTimeline}>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip> </Tooltip>
</Box> </Box>
{rows.length === 0 ? ( {loading && (
<Box display="flex" justifyContent="center" alignItems="center" minHeight={180}> <Box py={4} textAlign="center">
<Typography variant="body2" color="text.secondary" textAlign="center"> <CircularProgress size={24} />
No active agent activity right now.
</Typography>
</Box> </Box>
) : ( )}
{!loading && error && (
<Typography variant="body2" color="error">{error}</Typography>
)}
{!loading && !error && timeline.length === 0 && (
<Typography variant="body2" color="text.secondary">No team activity yet.</Typography>
)}
{!loading && !error && timeline.length > 0 && (
<List disablePadding> <List disablePadding>
{rows.map((agent, index) => ( {timeline.map(({ run, events }, index) => (
<React.Fragment key={agent.id}> <React.Fragment key={run.id}>
{index > 0 && <Divider variant="inset" component="li" sx={{ my: 1, ml: 7 }} />} {index > 0 && <Divider sx={{ my: 1 }} />}
<ListItem alignItems="flex-start" disableGutters sx={{ py: 0.5 }}> <ListItem disableGutters sx={{ display: 'block' }}>
<ListItemAvatar> <Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Avatar sx={{ bgcolor: '#eef2ff', color: '#6366f1', width: 40, height: 40 }}><agent.icon fontSize="small" /></Avatar> <Chip size="small" label={run.agent_type || 'Agent'} />
</ListItemAvatar> <Chip size="small" color={run.status === 'completed' ? 'success' : 'warning'} label={run.status} />
<ListItemText <Typography variant="caption" color="text.secondary">
primary={<Box display="flex" alignItems="center" gap={1}><Typography variant="subtitle2" fontWeight={600}>{agent.name}</Typography><Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem', border: '1px solid #e2e8f0', px: 0.5, borderRadius: 1 }}>{agent.role}</Typography></Box>} {run.started_at ? new Date(run.started_at).toLocaleString() : ''}
secondary={<Typography variant="body2" color="text.secondary" sx={{ display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical', overflow: 'hidden', fontSize: '0.75rem', mt: 0.25 }}>{agent.current_activity}</Typography>} </Typography>
/> </Stack>
{events.map((event) => {
const payload = event.payload || {};
return (
<Accordion key={event.id} disableGutters elevation={0} sx={{ border: '1px solid #e5e7eb', mb: 1 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ width: '100%' }}>
<Chip size="small" label={payload.phase || event.event_type} />
{payload.step && <Chip size="small" variant="outlined" label={payload.step} />}
<Typography variant="body2" sx={{ flexGrow: 1 }}>
{event.message || payload.output_summary || 'Activity update'}
</Typography>
{typeof payload.progress_percent === 'number' && (
<Typography variant="caption" color="text.secondary">{payload.progress_percent}%</Typography>
)}
</Stack>
</AccordionSummary>
<AccordionDetails>
<Typography variant="caption" display="block">Tool: {payload.tool_name || '—'}</Typography>
<Typography variant="caption" display="block">Input: {payload.input_summary || '—'}</Typography>
<Typography variant="caption" display="block">Output: {payload.output_summary || '—'}</Typography>
<Typography variant="caption" display="block">Decision: {payload.decision_reason || '—'}</Typography>
<Typography variant="caption" display="block">Evidence: {(payload.evidence_refs || []).join(', ') || '—'}</Typography>
<Typography variant="caption" display="block">Safe debug: {String(payload.safe_debug ?? true)}</Typography>
</AccordionDetails>
</Accordion>
);
})}
</ListItem> </ListItem>
</React.Fragment> </React.Fragment>
))} ))}
</List> </List>
)} )}
<Box mt={2} pt={2} borderTop="1px solid #eee" display="flex" justifyContent="center">
<Typography component={RouterLink} to="/team-activity" variant="caption" color="primary" sx={{ fontWeight: 600, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
View Full Team Activity
</Typography>
</Box>
</Paper> </Paper>
); );
}; };