Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ITBench
Submodule ITBench added at 29544a
13 changes: 11 additions & 2 deletions src/agentbox/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,11 @@ export function createHttpServer(sessionManager: AgentBoxSessionManager): http.S
// Subscribe to buffer events so SSE can replay them even if it connects late
const brainUnsub = managed.brain.subscribe((event) => {
if (!managed._promptDone) {
managed._eventBuffer.push(event);
// Stamp with server time when emitted so replayed events have accurate timestamps
const tsEvent = typeof event === "object" && event !== null
? { ...(event as object), ts: Date.now() }
: event;
managed._eventBuffer.push(tsEvent);
}
// Null dpState.checklist when deep_search completes — this is the exit signal
// for the SDK brain's auto-continue loop in claude-sdk-brain.ts.
Expand All @@ -343,6 +347,7 @@ export function createHttpServer(sessionManager: AgentBoxSessionManager): http.S
type: "tool_progress",
toolName: "deep_search",
progress: event,
ts: Date.now(),
});
}
// Sync phase events to SDK brain's dpState so the auto-continue loop
Expand Down Expand Up @@ -551,7 +556,11 @@ export function createHttpServer(sessionManager: AgentBoxSessionManager): http.S
if (closed || res.writableEnded) return;
try {
sseEventCount++;
const data = JSON.stringify(event);
// Add server timestamp if not already present (buffered events carry their original ts)
const out = typeof event === "object" && event !== null && !("ts" in (event as object))
? { ...(event as object), ts: Date.now() }
: event;
const data = JSON.stringify(out);
res.write(`data: ${data}\n\n`);
} catch (err) {
console.warn(`[agentbox-http] SSE write error for session ${sessionId}:`, err);
Expand Down
13 changes: 13 additions & 0 deletions src/gateway/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import fs from "node:fs";
import path from "node:path";

export interface ChannelConfig {
enabled: boolean;
[key: string]: unknown;
Expand All @@ -23,5 +26,15 @@ const DEFAULT_CONFIG: GatewayConfig = {
};

export function loadGatewayConfig(): GatewayConfig {
try {
// Read port from shared settings.json so one file controls everything
const configPath = process.env.SICLAW_CONFIG_DIR
? path.resolve(process.env.SICLAW_CONFIG_DIR, "settings.json")
: path.resolve(process.cwd(), ".siclaw", "config", "settings.json");
const raw = JSON.parse(fs.readFileSync(configPath, "utf-8")) as { server?: { port?: number } };
if (raw?.server?.port) {
return { ...DEFAULT_CONFIG, port: raw.server.port };
}
} catch { /* fall through to default */ }
return { ...DEFAULT_CONFIG };
}
144 changes: 143 additions & 1 deletion src/gateway/web/src/hooks/usePilot.ts

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion src/gateway/web/src/pages/Metrics/DashboardTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { SessionsChart } from './SessionsChart';
import { ToolCallsPanel } from './ToolCallsPanel';
import { SkillCallsPanel } from './SkillCallsPanel';
import { CumulativePanel } from './CumulativePanel';
import { TimingStatsPanel } from './TimingStatsPanel';

interface DashboardTabProps {
data: TimeseriesResponse | null;
Expand Down Expand Up @@ -48,7 +49,10 @@ export function DashboardTab({ data, range, loading }: DashboardTabProps) {
{/* Row 4: Sessions & Connections (full width) */}
<SessionsChart buckets={buckets} />

{/* Row 5: Cumulative Statistics (full width) */}
{/* Row 5: Response Timing Distribution (full width) */}
<TimingStatsPanel />

{/* Row 6: Cumulative Statistics (full width) */}
<CumulativePanel />
</div>
</div>
Expand Down
169 changes: 169 additions & 0 deletions src/gateway/web/src/pages/Metrics/TimingStatsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { useState, useEffect } from 'react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';

function readSamples(key: string): number[] {
try { return JSON.parse(localStorage.getItem(key) ?? '[]') as number[]; } catch { return []; }
}

function computeStats(samples: number[]) {
if (samples.length === 0) return null;
const sorted = [...samples].sort((a, b) => a - b);
const n = sorted.length;
const pct = (p: number) => sorted[Math.min(Math.ceil(n * p) - 1, n - 1)];
return {
min: sorted[0],
avg: Math.round(samples.reduce((s, v) => s + v, 0) / n),
p95: pct(0.95),
p99: pct(0.99),
max: sorted[n - 1],
count: n,
};
}

function fmt(ms: number | undefined): string {
if (ms == null) return '—';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}

type StatKey = 'min' | 'avg' | 'p95' | 'p99' | 'max';
const STAT_ROWS: { key: StatKey; label: string }[] = [
{ key: 'min', label: 'MIN' },
{ key: 'avg', label: 'AVG' },
{ key: 'p95', label: 'P95' },
{ key: 'p99', label: 'P99' },
{ key: 'max', label: 'MAX' },
];

const METRICS = [
{ key: 'siclaw_timing_ttft', label: 'TTFT', color: '#6366f1' },
{ key: 'siclaw_timing_llm', label: 'Thinking', color: '#f59e0b' },
{ key: 'siclaw_timing_tool', label: 'Tool Exec', color: '#10b981' },
] as const;

export function TimingStatsPanel() {
const [tick, setTick] = useState(0);

useEffect(() => {
const handler = () => setTick(t => t + 1);
window.addEventListener('siclaw_timing_update', handler);
return () => window.removeEventListener('siclaw_timing_update', handler);
}, []);

const stats = METRICS.map(m => ({
...m,
samples: tick >= 0 ? readSamples(m.key) : [],
stat: null as ReturnType<typeof computeStats>,
})).map(m => ({ ...m, stat: computeStats(m.samples) }));

const totalSamples = Math.max(...stats.map(m => m.samples.length));
const hasData = totalSamples > 0;

const handleClear = () => {
METRICS.forEach(m => localStorage.removeItem(m.key));
setTick(t => t + 1);
};

// Bar chart data: one bar per metric, height = avg
const barData = stats.map(m => ({
name: m.label,
avg: m.stat?.avg ?? 0,
color: m.color,
}));

return (
<div className="bg-white rounded-lg border border-gray-200 p-5">
<div className="flex items-center justify-between mb-5">
<div>
<h3 className="text-sm font-semibold text-gray-900">Response Timing Statistics</h3>
<p className="text-xs text-gray-400 mt-0.5">
TTFT, thinking, and tool execution times
{hasData ? ` — last ${totalSamples} / 200 calls` : ''}
</p>
</div>
{hasData && (
<button
type="button"
onClick={handleClear}
className="text-xs text-gray-400 hover:text-red-500 px-2 py-1 rounded hover:bg-red-50 transition-colors"
>
Clear
</button>
)}
</div>

{!hasData ? (
<div className="h-40 flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-200 rounded-lg">
No data yet — start a conversation to collect timing samples
</div>
) : (
<div className="flex gap-8 items-start">
{/* Left: bar chart comparing avg of each metric */}
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-400 mb-2">Average comparison</p>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={barData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} axisLine={false} tickLine={false} />
<YAxis
tickFormatter={(v: number) => fmt(v)}
tick={{ fontSize: 10 }}
width={56}
axisLine={false}
tickLine={false}
/>
<Tooltip
formatter={(v: unknown) => [fmt(v as number), 'Average']}
contentStyle={{ fontSize: 12, borderRadius: 6 }}
cursor={{ fill: '#f9fafb' }}
/>
<Bar dataKey="avg" radius={[4, 4, 0, 0]}>
{barData.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>

{/* Right: stats table */}
<div className="shrink-0">
<p className="text-xs text-gray-400 mb-2">Percentiles</p>
<table className="text-xs border-collapse">
<thead>
<tr>
<th className="text-left pr-4 pb-2 font-medium text-gray-400 w-10"></th>
{stats.map(m => (
<th key={m.key} className="text-right pr-4 pb-2 font-semibold" style={{ color: m.color }}>
{m.label}
</th>
))}
</tr>
</thead>
<tbody>
{STAT_ROWS.map(({ key, label }) => (
<tr key={key} className="border-t border-gray-100">
<td className="pr-4 py-1.5 font-medium text-gray-400">{label}</td>
{stats.map(m => (
<td key={m.key} className="text-right pr-4 py-1.5 font-mono text-gray-700">
{fmt(m.stat?.[key])}
</td>
))}
</tr>
))}
<tr className="border-t border-gray-200">
<td className="pr-4 pt-2 text-gray-400">n</td>
{stats.map(m => (
<td key={m.key} className="text-right pr-4 pt-2 font-mono text-gray-400">
{m.stat?.count ?? 0}
</td>
))}
</tr>
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
69 changes: 63 additions & 6 deletions src/gateway/web/src/pages/Pilot/components/PilotArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export interface PilotAreaProps {
/** Current workspace ID for cron job operations */
selectedWorkspaceId?: string | null;
isAdmin?: boolean;
/** performance.now() when the current prompt was sent — drives the top-level stopwatch */
loadingStartedAt?: number | null;
}

/** Compute superseded status for schedule messages */
Expand Down Expand Up @@ -108,7 +110,7 @@ function computeScheduleStatuses(messages: PilotMessage[]): Map<string, Schedule
return statuses;
}

export function PilotArea({ messages, isLoading, isLoadingHistory, wsStatus, isConnected, hasMore, isLoadingMore, sendMessage, abortResponse, loadMoreHistory, sendRpc, contextUsage, isCompacting, isRetrying, onOpenSchedulePanel, onOpenSkillPanel, updateMessageMeta, pendingMessages, onRemovePending, investigationProgress, dpActive, onSetDpActive, dpFocus, dpChecklist, onHypothesesConfirmed, onExitDp, systemStatus, onNavigateModels, onNavigateCredentials, sessionKey, selectedWorkspaceId, isAdmin }: PilotAreaProps) {
export function PilotArea({ messages, isLoading, isLoadingHistory, wsStatus, isConnected, hasMore, isLoadingMore, sendMessage, abortResponse, loadMoreHistory, sendRpc, contextUsage, isCompacting, isRetrying, onOpenSchedulePanel, onOpenSkillPanel, updateMessageMeta, pendingMessages, onRemovePending, investigationProgress, dpActive, onSetDpActive, dpFocus, dpChecklist, onHypothesesConfirmed, onExitDp, systemStatus, onNavigateModels, onNavigateCredentials, sessionKey, selectedWorkspaceId, isAdmin, loadingStartedAt }: PilotAreaProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const prevScrollHeightRef = useRef(0);
Expand Down Expand Up @@ -501,7 +503,7 @@ export function PilotArea({ messages, isLoading, isLoadingHistory, wsStatus, isC
<DpChecklistCard items={dpChecklist} investigationProgress={investigationProgress} onDismiss={onExitDp} />
)}

{isLoading && <ThinkingIndicator />}
{isLoading && <ThinkingIndicator startedAt={loadingStartedAt ?? undefined} />}

{showFeedbackHint && (
<div className={cn(
Expand Down Expand Up @@ -536,9 +538,10 @@ export function PilotArea({ messages, isLoading, isLoadingHistory, wsStatus, isC
);
}

function ThinkingIndicator() {
function ThinkingIndicator({ startedAt }: { startedAt?: number }) {
const [tipIndex, setTipIndex] = useState(0);
const [visible, setVisible] = useState(true);
const [elapsed, setElapsed] = useState(0);

useEffect(() => {
const interval = setInterval(() => {
Expand All @@ -551,6 +554,12 @@ function ThinkingIndicator() {
return () => clearInterval(interval);
}, []);

useEffect(() => {
if (!startedAt) return;
const tick = setInterval(() => setElapsed(Math.floor((performance.now() - startedAt) / 1000)), 1000);
return () => clearInterval(tick);
}, [startedAt]);

return (
<div className="flex gap-4">
<div className="w-8 h-8 rounded-full bg-white border border-gray-200 flex items-center justify-center text-primary-600 shadow-sm">
Expand All @@ -564,6 +573,9 @@ function ThinkingIndicator() {
)}>
{THINKING_TIPS[tipIndex]}
</span>
{startedAt != null && elapsed > 0 && (
<span className="text-xs font-mono text-gray-400">{elapsed}s</span>
)}
</div>
</div>
);
Expand Down Expand Up @@ -761,6 +773,12 @@ function MessageItem({ message, scheduleStatus, onOpenSchedulePanel, onOpenSkill
{message.isStreaming && !isUser && (
<Loader2 className="w-3 h-3 animate-spin text-gray-400" />
)}
{!message.isStreaming && !isUser && message.waitMs != null && message.waitMs > 100 && (
<span className="text-xs font-mono text-gray-400">⏳{formatDuration(message.waitMs)}</span>
)}
{!message.isStreaming && !isUser && message.llmDurationMs != null && (
<span className="text-xs font-mono text-gray-400">💭{formatDuration(message.llmDurationMs)}</span>
)}
</div>

{/* Reference chips (user messages only) */}
Expand Down Expand Up @@ -828,12 +846,38 @@ function MessageItem({ message, scheduleStatus, onOpenSchedulePanel, onOpenSkill
);
}

function ToolItemTimer({ startedAt }: { startedAt: number }) {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const tick = setInterval(() => setElapsed(Math.floor((performance.now() - startedAt) / 1000)), 1000);
return () => clearInterval(tick);
}, [startedAt]);
return <span className="text-xs font-mono text-blue-400 ml-auto shrink-0">{elapsed}s</span>;
}

function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}

function ToolItem({ message }: { message: PilotMessage }) {
const [expanded, setExpanded] = useState(false);
const isOpen = message.isStreaming || expanded;

return (
<div className="pl-12 min-w-0">
{message.waitMs != null && message.waitMs > 100 && (
<div className="flex items-center gap-1.5 px-3 py-1 text-xs text-gray-400 font-mono">
<span>⏳</span>
<span>Waiting {formatDuration(message.waitMs)}</span>
</div>
)}
{message.llmDurationMs != null && (
<div className="flex items-center gap-1.5 px-3 py-1 text-xs text-gray-400 font-mono">
<span>💭</span>
<span>Thinking {formatDuration(message.llmDurationMs)}</span>
</div>
)}
<div className="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
<button
type="button"
Expand All @@ -849,14 +893,27 @@ function ToolItem({ message }: { message: PilotMessage }) {
{message.toolInput && (
<span className="font-mono text-xs text-gray-500 truncate min-w-0">{message.toolInput}</span>
)}
{message.toolStatus === 'running' && (
{message.toolStatus === 'running' && message.startedAt != null && (
<ToolItemTimer startedAt={message.startedAt} />
)}
{message.toolStatus === 'running' && message.startedAt == null && (
<Loader2 className="w-3 h-3 animate-spin text-blue-400 ml-auto shrink-0" />
)}
{message.toolStatus === 'success' && (
<CheckCircle2 className="w-3.5 h-3.5 text-green-500 ml-auto shrink-0" />
<span className="flex items-center gap-1 ml-auto shrink-0">
{message.durationMs != null && (
<span className="text-xs font-mono text-gray-400">{formatDuration(message.durationMs)}</span>
)}
<CheckCircle2 className="w-3.5 h-3.5 text-green-500" />
</span>
)}
{message.toolStatus === 'error' && (
<XCircle className="w-3.5 h-3.5 text-red-500 ml-auto shrink-0" />
<span className="flex items-center gap-1 ml-auto shrink-0">
{message.durationMs != null && (
<span className="text-xs font-mono text-gray-400">{formatDuration(message.durationMs)}</span>
)}
<XCircle className="w-3.5 h-3.5 text-red-500" />
</span>
)}
{message.toolStatus === 'aborted' && (
<Ban className="w-3.5 h-3.5 text-amber-500 ml-auto shrink-0" />
Expand Down
Loading