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 dataclasses import asdict, dataclass, field
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.orm import Session
@@ -9,6 +10,73 @@ from sqlalchemy.orm import Session
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:
def __init__(self, db: Session, user_id: str):
self.db = db
@@ -51,10 +119,11 @@ class AgentActivityService:
event_type: str,
severity: str = "info",
message: Optional[str] = None,
payload: Optional[Dict[str, Any]] = None,
payload: Optional[Union[Dict[str, Any], AgentEventPayload]] = None,
run_id: Optional[int] = None,
agent_type: Optional[str] = None,
) -> AgentEvent:
normalized_payload = _normalize_event_payload(payload)
evt = AgentEvent(
run_id=run_id,
user_id=self.user_id,
@@ -62,7 +131,7 @@ class AgentActivityService:
event_type=event_type,
severity=severity,
message=message,
payload=payload,
payload=normalized_payload,
created_at=datetime.utcnow(),
)
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.intelligence.monitoring.semantic_dashboard import RealTimeSemanticMonitor
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
import time
@@ -504,7 +504,7 @@ class BaseALwrityAgent(ABC):
event_type="plan",
severity="info",
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,
agent_type=self.agent_type,
)
@@ -531,7 +531,7 @@ class BaseALwrityAgent(ABC):
event_type="final_summary",
severity="info",
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,
agent_type=self.agent_type,
)
@@ -545,7 +545,7 @@ class BaseALwrityAgent(ABC):
event_type="error",
severity="error",
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,
agent_type=self.agent_type,
)
@@ -591,7 +591,7 @@ class BaseALwrityAgent(ABC):
event_type="plan",
severity="info",
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,
agent_type=self.agent_type,
)
@@ -606,7 +606,7 @@ class BaseALwrityAgent(ABC):
event_type="decision",
severity="warning",
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,
agent_type=self.agent_type,
)
@@ -646,7 +646,7 @@ class BaseALwrityAgent(ABC):
event_type="decision",
severity="info",
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,
agent_type=self.agent_type,
)
@@ -671,7 +671,7 @@ class BaseALwrityAgent(ABC):
event_type="progress",
severity="info",
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,
agent_type=self.agent_type,
)
@@ -682,7 +682,7 @@ class BaseALwrityAgent(ABC):
event_type="warning",
severity="warning",
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,
agent_type=self.agent_type,
)
@@ -717,7 +717,7 @@ class BaseALwrityAgent(ABC):
event_type="final_summary",
severity="info",
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,
agent_type=self.agent_type,
)
@@ -768,7 +768,7 @@ class BaseALwrityAgent(ABC):
event_type="error",
severity="error",
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,
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.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 loguru import logger
@@ -430,7 +430,7 @@ async def generate_agent_enhanced_plan(db: Session, user_id: str, date: str) ->
event_type="plan",
severity="info",
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,
agent_type="TodayWorkflowGenerator",
)
@@ -449,7 +449,7 @@ async def generate_agent_enhanced_plan(db: Session, user_id: str, date: str) ->
event_type="warning",
severity="warning",
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,
agent_type="TodayWorkflowGenerator",
)
@@ -467,7 +467,7 @@ async def generate_agent_enhanced_plan(db: Session, user_id: str, date: str) ->
event_type="final_summary",
severity="info",
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,
agent_type="TodayWorkflowGenerator",
)

View File

@@ -3,106 +3,161 @@ import {
Box,
Paper,
Typography,
Avatar,
Chip,
List,
ListItem,
ListItemAvatar,
ListItemText,
Divider,
IconButton,
Tooltip
Tooltip,
CircularProgress,
Accordion,
AccordionSummary,
AccordionDetails,
Stack,
} from '@mui/material';
import {
Psychology as StrategyIcon,
Article as ContentIcon,
Search as SeoIcon,
Campaign as SocialIcon,
CompareArrows as CompetitorIcon,
Refresh as RefreshIcon
Refresh as RefreshIcon,
ExpandMore as ExpandMoreIcon,
} from '@mui/icons-material';
import { Link as RouterLink } from 'react-router-dom';
import { useAgentHuddleFeed } from '../../../hooks/useAgentHuddleFeed';
import { apiClient } from '../../../api/client';
const ICON_BY_AGENT: Record<string, React.ElementType> = {
strategy: StrategyIcon,
content: ContentIcon,
seo: SeoIcon,
social: SocialIcon,
competitor: CompetitorIcon,
type EventPayload = {
phase?: string | null;
step?: string | null;
tool_name?: string | null;
progress_percent?: number | null;
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 { 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
// Note: events are not directly used in the simple widget view, but available if needed
const rows = React.useMemo(() => {
return runs.slice(0, 5).map((run) => {
const agentType = String(run.agent_type || 'strategy');
// Simple heuristic for icon mapping
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';
return {
id: run.id,
name: agentType.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
role: run.status || 'active',
status,
current_activity: run.result_summary || run.error_message || 'Awaiting next update',
icon: IconComponent,
};
});
}, [runs]);
const loadTimeline = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const runsResp = await apiClient.get('/api/agents/runs', { params: { limit: 5 } });
const runs: AgentRun[] = runsResp?.data?.data?.runs || [];
const eventResponses = await Promise.all(
runs.slice(0, 3).map(async (run) => {
const eventsResp = await apiClient.get(`/api/agents/runs/${run.id}/events`, { params: { limit: 25 } });
return {
run,
events: (eventsResp?.data?.data?.events || []) as TeamActivityEvent[],
};
}),
);
setTimeline(eventResponses);
} catch (e: any) {
setError(e?.message || 'Failed to load team activity');
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
loadTimeline();
}, [loadTimeline]);
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" alignItems="center" gap={1}>
<Typography variant="h6" fontWeight={700} color="text.primary">Team Huddle</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 }} />
<Typography variant="h6" fontWeight={700}>Team Activity</Typography>
<Chip label="Live" size="small" color="success" sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700 }} />
</Box>
<Tooltip title={lastHeartbeatAt ? `Heartbeat ${new Date(lastHeartbeatAt).toLocaleTimeString()}` : 'Waiting for heartbeat'}>
<IconButton size="small"><RefreshIcon fontSize="small" /></IconButton>
<Tooltip title="Refresh Team Activity">
<IconButton size="small" onClick={loadTimeline}>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
{rows.length === 0 ? (
<Box display="flex" justifyContent="center" alignItems="center" minHeight={180}>
<Typography variant="body2" color="text.secondary" textAlign="center">
No active agent activity right now.
</Typography>
{loading && (
<Box py={4} textAlign="center">
<CircularProgress size={24} />
</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>
{rows.map((agent, index) => (
<React.Fragment key={agent.id}>
{index > 0 && <Divider variant="inset" component="li" sx={{ my: 1, ml: 7 }} />}
<ListItem alignItems="flex-start" disableGutters sx={{ py: 0.5 }}>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#eef2ff', color: '#6366f1', width: 40, height: 40 }}><agent.icon fontSize="small" /></Avatar>
</ListItemAvatar>
<ListItemText
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>}
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>}
/>
{timeline.map(({ run, events }, index) => (
<React.Fragment key={run.id}>
{index > 0 && <Divider sx={{ my: 1 }} />}
<ListItem disableGutters sx={{ display: 'block' }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Chip size="small" label={run.agent_type || 'Agent'} />
<Chip size="small" color={run.status === 'completed' ? 'success' : 'warning'} label={run.status} />
<Typography variant="caption" color="text.secondary">
{run.started_at ? new Date(run.started_at).toLocaleString() : ''}
</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>
</React.Fragment>
))}
</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>
);
};