From 49827a4e4597d7a0d522bb1f0f0104eeafc7d7ff Mon Sep 17 00:00:00 2001 From: arcgod-design Date: Wed, 1 Jul 2026 20:44:30 +0530 Subject: [PATCH 1/3] feat: merge SilenceBaseline into SilentFailureDetector (closes #478) --- agentwatch/scoring/silence.py | 71 +++++++++++++---------------------- 1 file changed, 27 insertions(+), 44 deletions(-) diff --git a/agentwatch/scoring/silence.py b/agentwatch/scoring/silence.py index f0c25cea..965d764c 100644 --- a/agentwatch/scoring/silence.py +++ b/agentwatch/scoring/silence.py @@ -18,18 +18,6 @@ from agentwatch.core.schema import AgentEvent, AgentSession, EventType, ExecutionStatus -@dataclass -class SilenceBaseline: - sample_size: int = 0 - mean_tool_calls: float = 0.0 - stdev_tool_calls: float = 0.0 - mean_duration_ms: float = 0.0 - stdev_duration_ms: float = 0.0 - mean_tokens: float = 0.0 - stdev_tokens: float = 0.0 - common_tools: list[str] = field(default_factory=list) - - @dataclass class SilenceFinding: session_id: str @@ -43,18 +31,25 @@ class SilentFailureDetector: def __init__(self, min_baseline: int = 5): self.min_baseline = min_baseline - self._baseline: SilenceBaseline | None = None + self.sample_size: int = 0 + self.mean_tool_calls: float = 0.0 + self.stdev_tool_calls: float = 0.0 + self.mean_duration_ms: float = 0.0 + self.stdev_duration_ms: float = 0.0 + self.mean_tokens: float = 0.0 + self.stdev_tokens: float = 0.0 + self.common_tools: list[str] = field(default_factory=list) # ── training ─────────────────────────────────────────────────────────── def train( self, sessions: list[tuple[AgentSession, list[AgentEvent]]], - ) -> SilenceBaseline: + ) -> None: ok = [(s, evs) for (s, evs) in sessions if s.status == ExecutionStatus.SUCCESS] if len(ok) < self.min_baseline: - self._baseline = SilenceBaseline(sample_size=len(ok)) - return self._baseline + self.sample_size = len(ok) + return tool_counts = [] durations = [] @@ -74,21 +69,14 @@ def train( def _safe_stdev(xs: Sequence[float]) -> float: return statistics.stdev(xs) if len(xs) > 1 else 0.0 - self._baseline = SilenceBaseline( - sample_size=len(ok), - mean_tool_calls=statistics.mean(tool_counts) if tool_counts else 0.0, - stdev_tool_calls=_safe_stdev(tool_counts), - mean_duration_ms=statistics.mean(durations) if durations else 0.0, - stdev_duration_ms=_safe_stdev(durations), - mean_tokens=statistics.mean(token_counts) if token_counts else 0.0, - stdev_tokens=_safe_stdev(token_counts), - common_tools=[t for t, _ in tool_freq.most_common(10)], - ) - return self._baseline - - @property - def baseline(self) -> SilenceBaseline | None: - return self._baseline + self.sample_size = len(ok) + self.mean_tool_calls = statistics.mean(tool_counts) if tool_counts else 0.0 + self.stdev_tool_calls = _safe_stdev(tool_counts) + self.mean_duration_ms = statistics.mean(durations) if durations else 0.0 + self.stdev_duration_ms = _safe_stdev(durations) + self.mean_tokens = statistics.mean(token_counts) if token_counts else 0.0 + self.stdev_tokens = _safe_stdev(token_counts) + self.common_tools = [t for t, _ in tool_freq.most_common(10)] # ── detection ────────────────────────────────────────────────────────── @@ -101,17 +89,14 @@ def detect( notes: dict[str, float] = {} if session.status != ExecutionStatus.SUCCESS: - # Not a silent failure — it announced itself return SilenceFinding( session_id=session.session_id, confidence=0.0, flags=["not_success"] ) - baseline = self._baseline calls = [e for e in events if e.event_type == EventType.TOOL_CALL] n_calls = len(calls) notes["tool_calls"] = float(n_calls) - # Heuristic flags if n_calls == 0 and any(e.event_type == EventType.PLANNER_OUTPUT for e in events): flags.append("planned_but_did_nothing") @@ -124,23 +109,21 @@ def detect( if session.total_tokens == 0: flags.append("zero_tokens") - # Baseline-based outliers (z-score > 2) - if baseline and baseline.sample_size >= self.min_baseline: - if baseline.stdev_tool_calls > 0: - z = abs(n_calls - baseline.mean_tool_calls) / baseline.stdev_tool_calls + if self.sample_size >= self.min_baseline: + if self.stdev_tool_calls > 0: + z = abs(n_calls - self.mean_tool_calls) / self.stdev_tool_calls if z > 2.0: flags.append(f"tool_count_outlier_z={z:.2f}") notes["z_tool_calls"] = z - if baseline.stdev_tokens > 0: - z = abs(session.total_tokens - baseline.mean_tokens) / baseline.stdev_tokens + if self.stdev_tokens > 0: + z = abs(session.total_tokens - self.mean_tokens) / self.stdev_tokens if z > 2.0: flags.append(f"token_count_outlier_z={z:.2f}") notes["z_tokens"] = z - # Tools used are entirely outside the common-set used_tools = {e.tool_call.tool_name for e in calls if e.tool_call} - if baseline and baseline.common_tools and used_tools: - if not (used_tools & set(baseline.common_tools)): + if self.common_tools and used_tools: + if not (used_tools & set(self.common_tools)): flags.append("all_uncommon_tools") confidence = min(1.0, len(flags) * 0.25) @@ -152,4 +135,4 @@ def detect( ) -__all__ = ["SilenceBaseline", "SilenceFinding", "SilentFailureDetector"] +__all__ = ["SilenceFinding", "SilentFailureDetector"] From 2689edbb2dd6953fb5736415ca349cd399c2e83d Mon Sep 17 00:00:00 2001 From: arcgod-design Date: Wed, 1 Jul 2026 20:51:55 +0530 Subject: [PATCH 2/3] fix: add baseline property for backward compatibility --- agentwatch/scoring/silence.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/agentwatch/scoring/silence.py b/agentwatch/scoring/silence.py index 965d764c..970aa5d7 100644 --- a/agentwatch/scoring/silence.py +++ b/agentwatch/scoring/silence.py @@ -42,6 +42,11 @@ def __init__(self, min_baseline: int = 5): # ── training ─────────────────────────────────────────────────────────── + @property + def baseline(self) -> SilentFailureDetector: + """Backward-compatible accessor — returns self (fields are now inline).""" + return self + def train( self, sessions: list[tuple[AgentSession, list[AgentEvent]]], From c2d991304ad1d9fbf903113fd364743c015546a8 Mon Sep 17 00:00:00 2001 From: arcgod-design Date: Sun, 5 Jul 2026 16:25:10 +0530 Subject: [PATCH 3/3] refactor: remove date-fns dependency, use native Intl.DateTimeFormat (closes #497) --- frontend/package.json | 1 - frontend/pages/index.tsx | 23 ++++++++++++++++++++--- frontend/pages/sessions/[id].tsx | 14 ++++++++++++-- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 8defcd90..13ea9af0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,7 +14,6 @@ "@tanstack/react-query": "^5.101.1", "@tanstack/react-query-devtools": "^5.101.1", "axios": "^1.18.1", - "date-fns": "^4.1.0", "lucide-react": "^0.454.0", "next": "14.2.30", "react": "^18.3.1", diff --git a/frontend/pages/index.tsx b/frontend/pages/index.tsx index 019018a5..2f38b7a6 100644 --- a/frontend/pages/index.tsx +++ b/frontend/pages/index.tsx @@ -4,7 +4,7 @@ import { useRouter } from 'next/router' import { useQueryClient } from '@tanstack/react-query' import { Activity, AlertTriangle, ChevronRight, DollarSign, Loader2, RefreshCw, Shield, Zap } from 'lucide-react' import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' -import { format, formatDistanceToNow } from 'date-fns' + import { AgentEvent, AgentSession, DashboardSummary } from '../lib/api' import { useLiveEventSocket } from '../lib/useLiveEventSocket' @@ -27,12 +27,29 @@ function cn(...classes: Array) { function safeFormat(ts: string | null | undefined, fmt: string): string { if (!ts) return '—' - try { return format(new Date(ts), fmt) } catch { return '—' } + try { + const date = new Date(ts) + if (fmt === 'HH:mm:ss') { + return new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date) + } + return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(date) + } catch { return '—' } } function safeDistanceToNow(ts: string | null | undefined): string { if (!ts) return '—' - try { return formatDistanceToNow(new Date(ts), { addSuffix: true }) } catch { return '—' } + try { + const now = Date.now() + const then = new Date(ts).getTime() + const diffSec = Math.round((now - then) / 1000) + if (diffSec < 60) return `${diffSec} seconds ago` + const diffMin = Math.round(diffSec / 60) + if (diffMin < 60) return `${diffMin} minutes ago` + const diffHr = Math.round(diffMin / 60) + if (diffHr < 24) return `${diffHr} hours ago` + const diffDay = Math.round(diffHr / 24) + return `${diffDay} days ago` + } catch { return '—' } } function statusBadge(status: string) { diff --git a/frontend/pages/sessions/[id].tsx b/frontend/pages/sessions/[id].tsx index 4058a9f1..8d3e0e87 100644 --- a/frontend/pages/sessions/[id].tsx +++ b/frontend/pages/sessions/[id].tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useRouter } from 'next/router' import { AlertTriangle, ArrowLeft, ChevronDown, ChevronUp, RotateCcw } from 'lucide-react' -import { format } from 'date-fns' + import { FailureAnalysis, ReplayStep } from '../../lib/api' import { Modal } from '../../components/Modal' @@ -9,7 +9,17 @@ import { useSession, useSessionReplay, useSessionConfidence, useSessionCheckpoin function safeFormat(ts: string | null | undefined, fmt: string): string { if (!ts) return '—' - try { return format(new Date(ts), fmt) } catch { return '—' } + try { + const date = new Date(ts) + if (fmt === 'HH:mm:ss.SSS') { + const time = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date) + return `${time}.${String(date.getMilliseconds()).padStart(3, '0')}` + } + if (fmt === 'HH:mm:ss') { + return new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date) + } + return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(date) + } catch { return '—' } } const STATUS_COLORS: Record = {