diff --git a/src/agentsight/dashboard/src/pages/AtifViewerPage.tsx b/src/agentsight/dashboard/src/pages/AtifViewerPage.tsx index 7642faa85..8e44b016b 100644 --- a/src/agentsight/dashboard/src/pages/AtifViewerPage.tsx +++ b/src/agentsight/dashboard/src/pages/AtifViewerPage.tsx @@ -3,7 +3,8 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import type { AtifDocument, AtifStep, AtifToolCall, AtifObservation, AtifStepMetrics, } from '../types'; -import { fetchAtifBySession, fetchAtifByConversation } from '../utils/apiClient'; +import { fetchAtifBySession, fetchAtifByConversation, fetchSessionSavings } from '../utils/apiClient'; +import type { SessionSavingsDetail, OptimizationItem } from '../utils/apiClient'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -27,6 +28,15 @@ function shortId(id: string, len = 20): string { return id.length > len ? id.slice(0, len) + '\u2026' : id; } +// ─── Strategy label config (shared with TokenSavingsPage) ──────────────────── + +const STRATEGY_LABELS: Record = { + 'compress-schema': { label: 'Schema 压缩', color: 'text-blue-700', bg: 'bg-blue-100' }, + 'compress-response': { label: '响应压缩', color: 'text-violet-700', bg: 'bg-violet-100' }, + 'rewrite-command': { label: '命令重写', color: 'text-orange-700', bg: 'bg-orange-100' }, + 'compress-toon': { label: 'TOON 编码', color: 'text-teal-700', bg: 'bg-teal-100' }, +}; + // ─── Source styling ─────────────────────────────────────────────────────────── const SOURCE_STYLES: Record = { @@ -121,9 +131,10 @@ interface StepCardProps { step: AtifStep; expandedSections: Set; onToggleSection: (key: string) => void; + savingsMap?: Map; } -const StepCard: React.FC = ({ step, expandedSections, onToggleSection }) => { +const StepCard: React.FC = ({ step, expandedSections, onToggleSection, savingsMap }) => { const style = getSourceStyle(step.source); const sectionKey = (name: string) => `${step.step_id}-${name}`; const isOpen = (name: string) => expandedSections.has(sectionKey(name)); @@ -197,7 +208,7 @@ const StepCard: React.FC = ({ step, expandedSections, onToggleSec >
{step.tool_calls!.map((tc, i) => ( - + ))}
@@ -263,12 +274,14 @@ const StepCard: React.FC = ({ step, expandedSections, onToggleSec // ─── ToolCallItem ───────────────────────────────────────────────────────────── -const ToolCallItem: React.FC<{ tc: AtifToolCall }> = ({ tc }) => { +const ToolCallItem: React.FC<{ tc: AtifToolCall; savingsMap?: Map }> = ({ tc, savingsMap }) => { const [showArgs, setShowArgs] = useState(false); const argsStr = typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments, null, 2); const isLongArgs = argsStr.length > 200; + const savings = savingsMap?.get(tc.tool_call_id); + const stratStyle = savings ? (STRATEGY_LABELS[savings.strategy] ?? { label: savings.strategy_label, color: 'text-gray-700', bg: 'bg-gray-100' }) : null; return (
@@ -277,6 +290,11 @@ const ToolCallItem: React.FC<{ tc: AtifToolCall }> = ({ tc }) => { {tc.function_name} {shortId(tc.tool_call_id, 16)} + {savings && stratStyle && ( + + 已优化 -{fmtTokens(savings.compounded_saved)} tokens ({stratStyle.label}) + + )} {isLongArgs && (
+ {/* Token Savings Comparison Card */} + {savingsDetail && savingsDetail.total_compounded_saved > 0 && ( +
+

Token 节省对比

+
+
+ 原始 Token(未优化) +

{fmtTokens(savingsDetail.total_original_tokens)}

+
+
+ 实际 Token(优化后) +

{fmtTokens(savingsDetail.total_actual_tokens)}

+
+
+ 节省 +

+ -{fmtTokens(savingsDetail.total_compounded_saved)} + + ({savingsDetail.savings_rate.toFixed(1)}%) + +

+
+
+ {/* Comparison bar */} +
+
+ 原始 +
+
+
+
+
+ 实际 +
+
0 + ? `${(savingsDetail.total_actual_tokens / savingsDetail.total_original_tokens) * 100}%` + : '100%', + }} + /> +
+
+
+
+ )} + {/* Step Timeline */}

@@ -624,6 +703,7 @@ export const AtifViewerPage: React.FC = () => { step={step} expandedSections={expandedSections} onToggleSection={toggleSection} + savingsMap={savingsMap} /> ))}

diff --git a/src/agentsight/dashboard/src/pages/TokenSavingsPage.tsx b/src/agentsight/dashboard/src/pages/TokenSavingsPage.tsx index c367e9890..b8c0214f3 100644 --- a/src/agentsight/dashboard/src/pages/TokenSavingsPage.tsx +++ b/src/agentsight/dashboard/src/pages/TokenSavingsPage.tsx @@ -4,7 +4,7 @@ import { PieChart, Pie, Cell, ResponsiveContainer, } from 'recharts'; import { fetchTokenSavings, fetchAgentNames } from '../utils/apiClient'; -import type { SessionSavings, SavingsSummary, OptimizationItem, DiffLine } from '../utils/apiClient'; +import type { SessionSavings, SavingsSummary, OptimizationItem, DiffLine, StrategyBreakdownItem } from '../utils/apiClient'; import { DateTimePicker } from '../components/DateTimePicker'; import { SessionIdHelp } from '../components/SessionIdHelp'; @@ -74,6 +74,15 @@ const CATEGORY_CONFIG: Record = { + 'compress-schema': { label: 'Schema 压缩', color: 'text-blue-700', bg: 'bg-blue-100', pie: '#3b82f6', tooltip: '精简工具/MCP 接口定义,减少上下文体积' }, + 'compress-response': { label: '响应压缩', color: 'text-violet-700', bg: 'bg-violet-100', pie: '#8b5cf6', tooltip: '清理响应冗余字段,保留语义关键内容' }, + 'rewrite-command': { label: '命令重写', color: 'text-orange-700', bg: 'bg-orange-100', pie: '#f59e0b', tooltip: '将工具命令重写为更精简的等价形式' }, + 'compress-toon': { label: 'TOON 编码', color: 'text-teal-700', bg: 'bg-teal-100', pie: '#14b8a6', tooltip: '将 JSON 输出转换为紧凑 TOON 表格文本' }, +}; + // ─── Pie chart data ─────────────────────────────────────────────────────────── const PIE_COLORS = ['#3b82f6', '#10b981']; // 输入蓝, 输出绿 @@ -113,6 +122,11 @@ const DiffView: React.FC<{ item: OptimizationItem }> = ({ item }) => { const OptimizationTableRow: React.FC<{ item: OptimizationItem }> = ({ item }) => { const [expanded, setExpanded] = useState(false); const cfg = CATEGORY_CONFIG[item.category]; + const stratCfg = STRATEGY_CONFIG[item.strategy] ?? { + label: item.strategy_label || item.strategy, + color: 'text-gray-700', bg: 'bg-gray-100', pie: '#9ca3af', + tooltip: '', + }; return ( <> @@ -122,6 +136,17 @@ const OptimizationTableRow: React.FC<{ item: OptimizationItem }> = ({ item }) => {cfg.label} + + + {stratCfg.label} + {stratCfg.tooltip && ( + + {stratCfg.tooltip} + + + )} + + {fmtTokens(item.before_tokens)} @@ -142,7 +167,7 @@ const OptimizationTableRow: React.FC<{ item: OptimizationItem }> = ({ item }) => {expanded && ( - + @@ -218,12 +243,15 @@ const SessionRow: React.FC<{ {/* Optimization items table */}
- +
+ @@ -446,44 +474,92 @@ export const TokenSavingsPage: React.FC = () => { - {/* Card 2: Saved tokens */} + {/* Card 2: Saved tokens — strategy breakdown pie */}

已降低 Token

{fmtTokens(totalCompoundedSaved)}

- - - - {SAVED_PIE_COLORS.map((c, i) => ( - - ))} - - - -
- - - 工具 {fmtTokens(totalCompoundedToolSaved)} - - - - MCP {fmtTokens(totalCompoundedMcpSaved)} - -
+ {(() => { + const breakdown = summary?.strategy_breakdown ?? []; + const hasStrategy = breakdown.length > 0 && breakdown.some(b => b.compounded_saved > 0); + if (hasStrategy) { + const pieData = breakdown + .filter(b => b.compounded_saved > 0) + .map(b => ({ + name: (STRATEGY_CONFIG[b.strategy]?.label ?? b.label), + value: b.compounded_saved, + color: (STRATEGY_CONFIG[b.strategy]?.pie ?? '#9ca3af'), + })); + return ( + <> + + + + {pieData.map((d, i) => ( + + ))} + + + +
+ {pieData.map((d, i) => ( + + + {d.name} {fmtTokens(d.value)} + + ))} +
+ + ); + } + // Fallback to category-level 2-slice pie + return ( + <> + + + + {SAVED_PIE_COLORS.map((c, i) => ( + + ))} + + + +
+ + + 工具 {fmtTokens(totalCompoundedToolSaved)} + + + + MCP {fmtTokens(totalCompoundedMcpSaved)} + +
+ + ); + })()}
diff --git a/src/agentsight/dashboard/src/test/AtifViewerPage.test.tsx b/src/agentsight/dashboard/src/test/AtifViewerPage.test.tsx index 8c3f57b7e..226611816 100644 --- a/src/agentsight/dashboard/src/test/AtifViewerPage.test.tsx +++ b/src/agentsight/dashboard/src/test/AtifViewerPage.test.tsx @@ -7,13 +7,15 @@ import { MemoryRouter } from 'react-router-dom'; vi.mock('../utils/apiClient', () => ({ fetchAtifBySession: vi.fn(), fetchAtifByConversation: vi.fn(), + fetchSessionSavings: vi.fn(), })); -import { fetchAtifBySession, fetchAtifByConversation } from '../utils/apiClient'; +import { fetchAtifBySession, fetchAtifByConversation, fetchSessionSavings } from '../utils/apiClient'; import { AtifViewerPage } from '../pages/AtifViewerPage'; const mockFetchAtifBySession = fetchAtifBySession as ReturnType; const mockFetchAtifByConversation = fetchAtifByConversation as ReturnType; +const mockFetchSessionSavings = fetchSessionSavings as ReturnType; function renderPage(route = '/atif') { return render( @@ -79,6 +81,8 @@ const mockAtifDoc = { beforeEach(() => { mockFetchAtifBySession.mockReset(); mockFetchAtifByConversation.mockReset(); + mockFetchSessionSavings.mockReset(); + mockFetchSessionSavings.mockRejectedValue(new Error('no savings')); }); describe('AtifViewerPage', () => { @@ -269,4 +273,48 @@ describe('AtifViewerPage', () => { }); expect(mockFetchAtifBySession).toHaveBeenCalledWith('sess-from-url'); }); + + it('should show Token savings comparison card when savings data exists', async () => { + mockFetchAtifBySession.mockResolvedValue(mockAtifDoc); + mockFetchSessionSavings.mockResolvedValue({ + session_id: 'sess-atif-test-123456789', + stats_available: true, + total_actual_tokens: 8000, + total_compounded_saved: 2000, + total_original_tokens: 10000, + savings_rate: 20.0, + items: [{ + id: 'tc-1', + category: 'tool_output', + strategy: 'compress-schema', + strategy_label: 'Schema 压缩', + title: 'Schema 压缩', + before_tokens: 500, + after_tokens: 200, + saved_tokens: 300, + compounded_saved: 600, + compounding_turns: 2, + before_summary: '原始内容 500 tokens', + after_summary: '优化后 200 tokens', + before_text: null, + after_text: null, + diff_lines: [], + }], + }); + + await act(async () => { + renderPage('/atif?type=session&id=sess-atif-test-123456789'); + }); + + // Wait for savings data to load + await act(async () => { + await new Promise(r => setTimeout(r, 50)); + }); + + expect(screen.getByText('Token 节省对比')).toBeInTheDocument(); + expect(screen.getByText('原始 Token(未优化)')).toBeInTheDocument(); + expect(screen.getByText('实际 Token(优化后)')).toBeInTheDocument(); + expect(screen.getByText('10,000')).toBeInTheDocument(); + expect(screen.getByText('8,000')).toBeInTheDocument(); + }); }); diff --git a/src/agentsight/dashboard/src/test/ConversationList.test.tsx b/src/agentsight/dashboard/src/test/ConversationList.test.tsx index ae16e2c93..a125a3fa9 100644 --- a/src/agentsight/dashboard/src/test/ConversationList.test.tsx +++ b/src/agentsight/dashboard/src/test/ConversationList.test.tsx @@ -365,7 +365,7 @@ describe('ConversationList', () => { last_seen_ns: Date.now() * 1_000_000, }]); mockFetchTokenSavings.mockResolvedValue({ - sessions: [{ session_id: 'sess-with-savings', saved_tokens: 500 }], + sessions: [{ session_id: 'sess-with-savings', compounded_saved: 500, saved_tokens: 500 }], summary: null, stats_available: true, }); diff --git a/src/agentsight/dashboard/src/test/TokenSavingsPage.test.tsx b/src/agentsight/dashboard/src/test/TokenSavingsPage.test.tsx index 4e8c1ebde..45654a438 100644 --- a/src/agentsight/dashboard/src/test/TokenSavingsPage.test.tsx +++ b/src/agentsight/dashboard/src/test/TokenSavingsPage.test.tsx @@ -110,6 +110,10 @@ describe('TokenSavingsPage', () => { total_compounded_tool_saved: 1200, total_compounded_mcp_saved: 800, compounded_savings_rate: 25.0, + strategy_breakdown: [ + { strategy: 'rewrite-command', label: '命令重写', saved: 600, compounded_saved: 1200 }, + { strategy: 'compress-response', label: '响应压缩', saved: 400, compounded_saved: 800 }, + ], }, stats_available: true, }); @@ -146,6 +150,7 @@ describe('TokenSavingsPage', () => { total_compounded_tool_saved: 200, total_compounded_mcp_saved: 100, compounded_savings_rate: 20.0, + strategy_breakdown: [], }, stats_available: true, }); @@ -157,7 +162,7 @@ describe('TokenSavingsPage', () => { expect(screen.getByText('TestAgent')).toBeInTheDocument(); }); - it('should expand session row to show optimization details', async () => { + it('should expand session row to show optimization details with strategy badge', async () => { mockFetchTokenSavings.mockResolvedValue({ sessions: [{ session_id: 'sess-expand-test', @@ -170,6 +175,8 @@ describe('TokenSavingsPage', () => { optimization_items: [{ id: 'opt-1', category: 'tool_output', + strategy: 'compress-schema', + strategy_label: 'Schema 压缩', before_tokens: 400, after_tokens: 100, compounded_saved: 300, @@ -186,6 +193,9 @@ describe('TokenSavingsPage', () => { total_compounded_tool_saved: 300, total_compounded_mcp_saved: 200, compounded_savings_rate: 16.7, + strategy_breakdown: [ + { strategy: 'compress-schema', label: 'Schema 压缩', saved: 300, compounded_saved: 300 }, + ], }, stats_available: true, }); @@ -199,6 +209,7 @@ describe('TokenSavingsPage', () => { fireEvent.click(row!); }); expect(screen.getByText('工具输出')).toBeInTheDocument(); + expect(screen.getByText('Schema 压缩')).toBeInTheDocument(); expect(screen.getAllByText('详情').length).toBeGreaterThanOrEqual(1); }); @@ -213,6 +224,7 @@ describe('TokenSavingsPage', () => { total_compounded_tool_saved: 3000, total_compounded_mcp_saved: 1000, compounded_savings_rate: 50.0, + strategy_breakdown: [], }, stats_available: true, }); diff --git a/src/agentsight/dashboard/src/utils/apiClient.ts b/src/agentsight/dashboard/src/utils/apiClient.ts index ce2d826c1..609008ddc 100644 --- a/src/agentsight/dashboard/src/utils/apiClient.ts +++ b/src/agentsight/dashboard/src/utils/apiClient.ts @@ -204,6 +204,8 @@ export interface OptimizationItem { id: string; category: 'tool_output' | 'mcp_response'; title: string; + strategy: string; + strategy_label: string; before_tokens: number; after_tokens: number; saved_tokens: number; @@ -232,6 +234,13 @@ export interface SessionSavings { optimization_items: OptimizationItem[]; } +export interface StrategyBreakdownItem { + strategy: string; + label: string; + saved: number; + compounded_saved: number; +} + export interface SavingsSummary { total_input_tokens: number; total_output_tokens: number; @@ -244,6 +253,7 @@ export interface SavingsSummary { total_mcp_saved: number; total_compounded_tool_saved: number; total_compounded_mcp_saved: number; + strategy_breakdown: StrategyBreakdownItem[]; } export interface TokenSavingsResponse { @@ -268,6 +278,29 @@ export async function fetchTokenSavings( return apiFetch(`${API_BASE}/api/token-savings?${params.toString()}`); } +// ─── Session-scoped Token Savings ───────────────────────────────────────────── + +export interface SessionSavingsDetail { + session_id: string; + stats_available: boolean; + total_actual_tokens: number; + total_compounded_saved: number; + total_original_tokens: number; + savings_rate: number; + items: OptimizationItem[]; +} + +/** + * Fetch token savings detail for a single session. + */ +export async function fetchSessionSavings( + sessionId: string, +): Promise { + return apiFetch( + `${API_BASE}/api/token-savings/session/${encodeURIComponent(sessionId)}` + ); +} + /** * Export a single trace as an ATIF v1.6 trajectory document. */ diff --git a/src/agentsight/src/server/handlers.rs b/src/agentsight/src/server/handlers.rs index 9d2cf1ee7..979e86fad 100644 --- a/src/agentsight/src/server/handlers.rs +++ b/src/agentsight/src/server/handlers.rs @@ -12,7 +12,6 @@ use crate::agent_sec::{AgentSecClient, AgentSecClientError, DaemonResponse}; use crate::health::AgentHealthStatus; use crate::storage::sqlite::GenAISqliteStore; use crate::storage::sqlite::genai::{ModelTimeseriesBucket, TimeseriesBucket}; -use crate::storage::sqlite::tokenless::{self, TokenlessStatsStore}; // ─── Prometheus helpers ─────────────────────────────────────────────────────── @@ -1017,7 +1016,6 @@ pub async fn restart_agent_health( ) -> impl Responder { let pid = path.into_inner(); - // 从 store 中取出 restart_cmd let restart_cmd = { let store = data.health_store.read().unwrap(); store @@ -1044,17 +1042,16 @@ pub async fn restart_agent_health( .json(serde_json::json!({"error": format!("kill failed: {}", e)})); } - // Step 2: 短暂等待进程退出 + // Step 2: short wait for process to exit std::thread::sleep(std::time::Duration::from_millis(500)); - // Step 3: re-exec(后台启动,不等待) + // Step 3: re-exec (background, don't wait) let exe = &cmd[0]; let args = &cmd[1..]; match Command::new(exe).args(args).spawn() { Ok(child) => { let new_pid = child.id(); log::info!("Restarted agent pid={pid} -> new pid={new_pid}, cmd={cmd:?}"); - // 从 store 中删除旧 PID 条目,下次扫描时新 PID 会自动加入 data.health_store.write().unwrap().remove_by_pid(pid); HttpResponse::Ok().json(serde_json::json!({ "ok": true, @@ -1317,8 +1314,6 @@ pub async fn interruption_stats( /// GET /api/interruptions/session-counts?start_ns=&end_ns= /// /// Returns unresolved interruption breakdown per session_id, grouped by severity and type. -/// Response: [ { session_id, total, by_severity: { critical, high, medium, low }, -/// types: [ { interruption_type, severity, count }, ... ] }, ... ] #[get("/api/interruptions/session-counts")] pub async fn interruption_session_counts( data: web::Data, @@ -1336,7 +1331,6 @@ pub async fn interruption_session_counts( match istore.count_unresolved_by_session_detailed(start_ns, end_ns) { Ok(rows) => { - // Group by session_id let mut map: std::collections::HashMap< String, ( @@ -1384,8 +1378,6 @@ pub async fn interruption_session_counts( /// GET /api/interruptions/conversation-counts?start_ns=&end_ns= /// /// Returns unresolved interruption breakdown per conversation_id, grouped by severity and type. -/// Response: [ { conversation_id, total, by_severity: { critical, high, medium, low }, -/// types: [ { interruption_type, severity, count }, ... ] }, ... ] #[get("/api/interruptions/conversation-counts")] pub async fn interruption_conversation_counts( data: web::Data, @@ -1541,306 +1533,6 @@ pub async fn get_interruption( } } -// ─── Token Savings endpoint ───────────────────────────────────────────────── - -/// Query parameters for /api/token-savings -#[derive(Debug, Deserialize)] -pub struct TokenSavingsQuery { - pub start_ns: Option, - pub end_ns: Option, - pub agent_name: Option, -} - -/// Overall savings summary -#[derive(Debug, Serialize)] -pub struct SavingsSummary { - pub total_input_tokens: i64, - pub total_output_tokens: i64, - pub total_tokens: i64, - pub total_saved_tokens: i64, - pub total_compounded_saved: i64, - pub savings_rate: f64, - pub compounded_savings_rate: f64, - pub total_tool_saved: i64, - pub total_mcp_saved: i64, - pub total_compounded_tool_saved: i64, - pub total_compounded_mcp_saved: i64, -} - -/// A single optimization item within a session -#[derive(Debug, Serialize)] -pub struct OptimizationItemDto { - pub id: String, - pub category: String, - pub title: String, - pub before_tokens: i64, - pub after_tokens: i64, - pub saved_tokens: i64, - pub compounded_saved: i64, - pub compounding_turns: i64, - pub before_summary: String, - pub after_summary: String, - pub before_text: Option, - pub after_text: Option, - pub diff_lines: Vec, -} - -/// A single diff line -#[derive(Debug, Serialize)] -pub struct DiffLineDto { - #[serde(rename = "type")] - pub line_type: String, - pub content: String, -} - -/// Per-session savings data -#[derive(Debug, Serialize)] -pub struct SessionSavingsDto { - pub session_id: String, - pub agent_name: String, - pub total_input_tokens: i64, - pub total_output_tokens: i64, - pub total_tokens: i64, - pub saved_tokens: i64, - pub compounded_saved: i64, - pub savings_rate: f64, - pub compounded_savings_rate: f64, - pub request_count: i64, - pub tool_saved: i64, - pub mcp_saved: i64, - pub optimization_items: Vec, -} - -/// Full response for /api/token-savings -#[derive(Debug, Serialize)] -pub struct TokenSavingsResponse { - pub stats_available: bool, - pub summary: SavingsSummary, - pub sessions: Vec, -} - -/// Map stats.db operation field to frontend category. -fn map_operation_to_category(operation: &str) -> &str { - match operation { - "compress-response" => "mcp_response", - "rewrite-command" => "tool_output", - _ => "tool_output", - } -} - -/// Map operation to a human-readable title. -fn map_operation_to_title(operation: &str) -> &str { - match operation { - "compress-response" => "MCP\u{54cd}\u{5e94}\u{538b}\u{7f29}", - "rewrite-command" => "\u{5de5}\u{5177}\u{8f93}\u{51fa}\u{4f18}\u{5316}", - _ => "\u{5de5}\u{5177}\u{8f93}\u{51fa}\u{4f18}\u{5316}", - } -} - -/// GET /api/token-savings?start_ns=&end_ns=&agent_name= -/// -/// Returns token savings data by cross-referencing genai_events.db -/// with the external ~/.tokenless/stats.db. -#[get("/api/token-savings")] -pub async fn get_token_savings( - data: web::Data, - query: web::Query, -) -> impl Responder { - let db_path = &data.storage_path; - let end_ns = query.end_ns.unwrap_or_else(|| now_ns() as i64); - let start_ns = query - .start_ns - .unwrap_or_else(|| end_ns - 86_400_000_000_000i64); - let agent_name = query.agent_name.as_deref(); - - // Step 1: Query sessions from genai_events.db - let sessions = match GenAISqliteStore::new_with_path(db_path) { - Ok(store) => match store.list_sessions_for_savings(start_ns, end_ns, agent_name) { - Ok(s) => s, - Err(e) => { - return HttpResponse::InternalServerError() - .json(serde_json::json!({"error": e.to_string()})); - } - }, - Err(e) => { - return HttpResponse::InternalServerError() - .json(serde_json::json!({"error": e.to_string()})); - } - }; - - // Step 2: Open stats.db (read-only, graceful if absent) - let stats_path = tokenless::default_stats_path(); - let stats_store = TokenlessStatsStore::open_if_exists(&stats_path); - let stats_available = stats_store.is_some(); - - // Step 3: Build tool_call_id → (turn_index, session_id) map from genai_events. - // This gives us all known tool_use_ids and their session membership. - let session_ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect(); - let turn_indices = match GenAISqliteStore::new_with_path(db_path) { - Ok(store) => store - .get_tool_call_turn_indices(&session_ids) - .unwrap_or_default(), - Err(_) => std::collections::HashMap::new(), - }; - - // Step 4: Query stats.db by tool_use_ids (instead of session_ids) - let stats_by_session = if let Some(ref store) = stats_store { - let tool_use_ids: Vec<&str> = turn_indices.keys().map(|s| s.as_str()).collect(); - let rows = store.get_stats_by_tool_use_ids(&tool_use_ids); - // Group by session: use turn_indices to determine session, fallback to row.session_id - let mut map: std::collections::HashMap> = std::collections::HashMap::new(); - for row in rows { - let sid = turn_indices - .get(&row.tool_use_id) - .map(|info| info.session_id.clone()) - .unwrap_or_else(|| row.session_id.clone()); - map.entry(sid).or_default().push(row); - } - map - } else { - std::collections::HashMap::new() - }; - - // Step 5: Build response - let mut resp_sessions = Vec::with_capacity(sessions.len()); - let mut grand_input: i64 = 0; - let mut grand_output: i64 = 0; - let mut grand_saved: i64 = 0; - let mut grand_compounded_saved: i64 = 0; - let mut grand_tool_saved: i64 = 0; - let mut grand_mcp_saved: i64 = 0; - let mut grand_compounded_tool_saved: i64 = 0; - let mut grand_compounded_mcp_saved: i64 = 0; - - for session in &sessions { - let total_tokens = session.total_input_tokens + session.total_output_tokens; - let request_count = session.request_count; - let mut session_saved: i64 = 0; - let mut session_compounded_saved: i64 = 0; - let mut session_tool_saved: i64 = 0; - let mut session_mcp_saved: i64 = 0; - let mut session_compounded_tool_saved: i64 = 0; - let mut session_compounded_mcp_saved: i64 = 0; - let mut items = Vec::new(); - - if let Some(stat_rows) = stats_by_session.get(&session.session_id) { - for row in stat_rows { - let saved = row.before_tokens - row.after_tokens; - let category = map_operation_to_category(&row.operation); - let title = map_operation_to_title(&row.operation); - - // Compounding: the shortened tool output appears in the - // context of all LLM calls AFTER the one that triggered the - // tool use. If the tool was invoked at turn N (1-based) out - // of M total turns, the savings persist for (M - N) turns. - let turn_index = turn_indices - .get(&row.tool_use_id) - .map(|info| info.turn_index) - .unwrap_or(1) as i64; - let compounding_turns = (request_count - turn_index).max(1); - let compounded = saved * compounding_turns; - - if category == "mcp_response" { - session_mcp_saved += saved; - session_compounded_mcp_saved += compounded; - } else { - session_tool_saved += saved; - session_compounded_tool_saved += compounded; - } - session_saved += saved; - session_compounded_saved += compounded; - - let diff_lines: Vec = Vec::new(); - - items.push(OptimizationItemDto { - id: row.tool_use_id.clone(), - category: category.to_string(), - title: title.to_string(), - before_tokens: row.before_tokens, - after_tokens: row.after_tokens, - saved_tokens: saved, - compounded_saved: compounded, - compounding_turns, - before_summary: format!( - "\u{539f}\u{59cb}\u{5185}\u{5bb9} {} tokens", - row.before_tokens - ), - after_summary: format!("\u{4f18}\u{5316}\u{540e} {} tokens", row.after_tokens), - before_text: row.before_text.clone(), - after_text: row.after_text.clone(), - diff_lines, - }); - } - } - - let savings_rate = if total_tokens > 0 { - session_saved as f64 / total_tokens as f64 * 100.0 - } else { - 0.0 - }; - let compounded_savings_rate = if total_tokens > 0 { - session_compounded_saved as f64 / total_tokens as f64 * 100.0 - } else { - 0.0 - }; - - grand_input += session.total_input_tokens; - grand_output += session.total_output_tokens; - grand_saved += session_saved; - grand_compounded_saved += session_compounded_saved; - grand_tool_saved += session_tool_saved; - grand_mcp_saved += session_mcp_saved; - grand_compounded_tool_saved += session_compounded_tool_saved; - grand_compounded_mcp_saved += session_compounded_mcp_saved; - - resp_sessions.push(SessionSavingsDto { - session_id: session.session_id.clone(), - agent_name: session.agent_name.clone().unwrap_or_default(), - total_input_tokens: session.total_input_tokens, - total_output_tokens: session.total_output_tokens, - total_tokens, - saved_tokens: session_saved, - compounded_saved: session_compounded_saved, - savings_rate, - compounded_savings_rate, - request_count, - tool_saved: session_tool_saved, - mcp_saved: session_mcp_saved, - optimization_items: items, - }); - } - - let grand_total = grand_input + grand_output; - let grand_rate = if grand_total > 0 { - grand_saved as f64 / grand_total as f64 * 100.0 - } else { - 0.0 - }; - let grand_compounded_rate = if grand_total > 0 { - grand_compounded_saved as f64 / grand_total as f64 * 100.0 - } else { - 0.0 - }; - - HttpResponse::Ok().json(TokenSavingsResponse { - stats_available, - summary: SavingsSummary { - total_input_tokens: grand_input, - total_output_tokens: grand_output, - total_tokens: grand_total, - total_saved_tokens: grand_saved, - total_compounded_saved: grand_compounded_saved, - savings_rate: grand_rate, - compounded_savings_rate: grand_compounded_rate, - total_tool_saved: grand_tool_saved, - total_mcp_saved: grand_mcp_saved, - total_compounded_tool_saved: grand_compounded_tool_saved, - total_compounded_mcp_saved: grand_compounded_mcp_saved, - }, - sessions: resp_sessions, - }) -} - // ─── Skill Metrics endpoints ───────────────────────────────────────────────── /// Query parameters for skill metrics endpoints. diff --git a/src/agentsight/src/server/mod.rs b/src/agentsight/src/server/mod.rs index 0df515d49..6dab4b4c2 100644 --- a/src/agentsight/src/server/mod.rs +++ b/src/agentsight/src/server/mod.rs @@ -4,6 +4,7 @@ //! AgentSight storage data, and optionally serves the embedded frontend. mod handlers; +mod token_savings; use std::path::PathBuf; use std::sync::{Arc, RwLock}; @@ -143,7 +144,8 @@ fn configure_routes(cfg: &mut web::ServiceConfig) { .service(handlers::list_conversation_interruptions) .service(handlers::resolve_interruption) .service(handlers::get_interruption) - .service(handlers::get_token_savings) + .service(token_savings::get_token_savings) + .service(token_savings::get_session_savings) // agent-sec Security Observability API routes .service(handlers::security_status) .service(handlers::security_summary) diff --git a/src/agentsight/src/server/token_savings.rs b/src/agentsight/src/server/token_savings.rs new file mode 100644 index 000000000..7ddab1e83 --- /dev/null +++ b/src/agentsight/src/server/token_savings.rs @@ -0,0 +1,838 @@ +//! Token Savings API handlers +//! +//! Provides endpoints that cross-reference genai_events.db with the external +//! ~/.tokenless/stats.db to compute token savings metrics. + +use actix_web::{HttpResponse, Responder, get, web}; +use serde::{Deserialize, Serialize}; + +use super::AppState; +use crate::storage::sqlite::GenAISqliteStore; +use crate::storage::sqlite::tokenless::{self, TokenlessStatsStore}; + +// ─── Query parameters ──────────────────────────────────────────────────────── + +/// Query parameters for /api/token-savings +#[derive(Debug, Deserialize)] +pub struct TokenSavingsQuery { + pub start_ns: Option, + pub end_ns: Option, + pub agent_name: Option, +} + +// ─── Response DTOs ─────────────────────────────────────────────────────────── + +/// Per-strategy saved amounts +#[derive(Debug, Serialize)] +pub struct StrategyBreakdown { + pub strategy: String, + pub label: String, + pub saved: i64, + pub compounded_saved: i64, +} + +/// Overall savings summary +#[derive(Debug, Serialize)] +pub struct SavingsSummary { + pub total_input_tokens: i64, + pub total_output_tokens: i64, + pub total_tokens: i64, + pub total_saved_tokens: i64, + pub total_compounded_saved: i64, + pub savings_rate: f64, + pub compounded_savings_rate: f64, + pub total_tool_saved: i64, + pub total_mcp_saved: i64, + pub total_compounded_tool_saved: i64, + pub total_compounded_mcp_saved: i64, + pub strategy_breakdown: Vec, +} + +/// A single optimization item within a session +#[derive(Debug, Serialize, Clone)] +pub struct OptimizationItemDto { + pub id: String, + pub category: String, + pub title: String, + pub strategy: String, + pub strategy_label: String, + pub before_tokens: i64, + pub after_tokens: i64, + pub saved_tokens: i64, + pub compounded_saved: i64, + pub compounding_turns: i64, + pub before_summary: String, + pub after_summary: String, + pub before_text: Option, + pub after_text: Option, + pub diff_lines: Vec, +} + +/// A single diff line +#[derive(Debug, Serialize, Clone)] +pub struct DiffLineDto { + #[serde(rename = "type")] + pub line_type: String, + pub content: String, +} + +/// Per-session savings data +#[derive(Debug, Serialize)] +pub struct SessionSavingsDto { + pub session_id: String, + pub agent_name: String, + pub total_input_tokens: i64, + pub total_output_tokens: i64, + pub total_tokens: i64, + pub saved_tokens: i64, + pub compounded_saved: i64, + pub savings_rate: f64, + pub compounded_savings_rate: f64, + pub request_count: i64, + pub tool_saved: i64, + pub mcp_saved: i64, + pub optimization_items: Vec, +} + +/// Full response for /api/token-savings +#[derive(Debug, Serialize)] +pub struct TokenSavingsResponse { + pub stats_available: bool, + pub summary: SavingsSummary, + pub sessions: Vec, +} + +/// Response for /api/token-savings/session/{session_id} +#[derive(Debug, Serialize)] +pub struct SessionSavingsDetail { + pub session_id: String, + pub stats_available: bool, + pub total_actual_tokens: i64, + pub total_compounded_saved: i64, + pub total_original_tokens: i64, + pub savings_rate: f64, + pub items: Vec, +} + +// ─── Mapping helpers ───────────────────────────────────────────────────────── + +/// Map stats.db `operation` field to frontend category. +/// +/// Classification rationale: +/// - `compress-response` / `compress-toon`: both compress MCP server responses +/// (toon uses a structured encoding variant), hence `mcp_response`. +/// - `rewrite-command` / `compress-schema`: both reduce tool-definition / +/// invocation payloads sent to the LLM, hence `tool_output`. +fn map_operation_to_category(operation: &str) -> &str { + match operation { + // MCP response compression strategies + "compress-response" | "compress-toon" => "mcp_response", + // Tool definition / invocation compression strategies + "rewrite-command" | "compress-schema" => "tool_output", + _ => "tool_output", + } +} + +/// Map operation to a human-readable title. +fn map_operation_to_title(operation: &str) -> &str { + match operation { + "compress-response" => "MCP响应压缩", + "rewrite-command" => "工具输出优化", + "compress-schema" => "Schema 压缩", + "compress-toon" => "TOON 编码", + _ => "其他优化", + } +} + +/// Map operation to a human-readable strategy label. +/// +/// Note: unknown operations all map to "其他优化", and the aggregation logic +/// uses this label as the grouping key to avoid duplicate pie chart slices. +fn map_operation_to_strategy_label(operation: &str) -> &str { + match operation { + "compress-schema" => "Schema 压缩", + "compress-response" => "响应压缩", + "rewrite-command" => "命令重写", + "compress-toon" => "TOON 编码", + _ => "其他优化", + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/// Current UNIX time in nanoseconds +fn now_ns() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() as u64 +} + +// ─── GET /api/token-savings ────────────────────────────────────────────────── + +/// GET /api/token-savings?start_ns=&end_ns=&agent_name= +/// +/// Returns token savings data by cross-referencing genai_events.db +/// with the external ~/.tokenless/stats.db. +#[get("/api/token-savings")] +pub async fn get_token_savings( + data: web::Data, + query: web::Query, +) -> impl Responder { + let db_path = &data.storage_path; + let end_ns = query.end_ns.unwrap_or_else(|| now_ns() as i64); + let start_ns = query + .start_ns + .unwrap_or_else(|| end_ns - 86_400_000_000_000i64); + let agent_name = query.agent_name.as_deref(); + + // Step 1: Query sessions from genai_events.db + let sessions = match GenAISqliteStore::new_with_path(db_path) { + Ok(store) => match store.list_sessions_for_savings(start_ns, end_ns, agent_name) { + Ok(s) => s, + Err(e) => { + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})); + } + }, + Err(e) => { + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})); + } + }; + + // Step 2: Open stats.db (read-only, graceful if absent) + let stats_path = tokenless::default_stats_path(); + let stats_store = TokenlessStatsStore::open_if_exists(&stats_path); + let stats_available = stats_store.is_some(); + + // Step 3: Build tool_call_id → (turn_index, session_id) map from genai_events. + // This gives us all known tool_use_ids and their session membership. + let session_ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect(); + let turn_indices = match GenAISqliteStore::new_with_path(db_path) { + Ok(store) => store + .get_tool_call_turn_indices(&session_ids) + .unwrap_or_default(), + Err(_) => std::collections::HashMap::new(), + }; + + // Step 4: Query stats.db by tool_use_ids (instead of session_ids) + let stats_by_session = if let Some(ref store) = stats_store { + let tool_use_ids: Vec<&str> = turn_indices.keys().map(|s| s.as_str()).collect(); + let rows = store.get_stats_by_tool_use_ids(&tool_use_ids); + // Group by session: use turn_indices to determine session, fallback to row.session_id + let mut map: std::collections::HashMap> = std::collections::HashMap::new(); + for row in rows { + let sid = turn_indices + .get(&row.tool_use_id) + .map(|info| info.session_id.clone()) + .unwrap_or_else(|| row.session_id.clone()); + map.entry(sid).or_default().push(row); + } + map + } else { + std::collections::HashMap::new() + }; + + // Step 5: Build response + let mut resp_sessions = Vec::with_capacity(sessions.len()); + let mut grand_input: i64 = 0; + let mut grand_output: i64 = 0; + let mut grand_saved: i64 = 0; + let mut grand_compounded_saved: i64 = 0; + let mut grand_tool_saved: i64 = 0; + let mut grand_mcp_saved: i64 = 0; + let mut grand_compounded_tool_saved: i64 = 0; + let mut grand_compounded_mcp_saved: i64 = 0; + // FIX(#2): aggregate by strategy *label* (not raw operation) so that + // unknown operations merge into a single "其他优化" slice in the pie chart. + let mut grand_strategy_map: std::collections::HashMap = + std::collections::HashMap::new(); + + for session in &sessions { + let total_tokens = session.total_input_tokens + session.total_output_tokens; + let request_count = session.request_count; + let mut session_saved: i64 = 0; + let mut session_compounded_saved: i64 = 0; + let mut session_tool_saved: i64 = 0; + let mut session_mcp_saved: i64 = 0; + let mut session_compounded_tool_saved: i64 = 0; + let mut session_compounded_mcp_saved: i64 = 0; + let mut items = Vec::new(); + + if let Some(stat_rows) = stats_by_session.get(&session.session_id) { + for row in stat_rows { + let saved = row.before_tokens - row.after_tokens; + let category = map_operation_to_category(&row.operation); + let title = map_operation_to_title(&row.operation); + + // Compounding: the shortened tool output appears in the + // context of all LLM calls AFTER the one that triggered the + // tool use. If the tool was invoked at turn N (1-based) out + // of M total turns, the savings persist for (M - N) turns. + let turn_index = turn_indices + .get(&row.tool_use_id) + .map(|info| info.turn_index) + .unwrap_or(1) as i64; + let compounding_turns = (request_count - turn_index).max(1); + let compounded = saved * compounding_turns; + + if category == "mcp_response" { + session_mcp_saved += saved; + session_compounded_mcp_saved += compounded; + } else { + session_tool_saved += saved; + session_compounded_tool_saved += compounded; + } + session_saved += saved; + session_compounded_saved += compounded; + + let diff_lines: Vec = Vec::new(); + + let strategy = row.operation.clone(); + let strategy_label = map_operation_to_strategy_label(&row.operation).to_string(); + + // FIX(#2): aggregate by strategy key so unknown ops merge into one slice. + // Use operation name as key for known ops (frontend STRATEGY_CONFIG matches on this), + // and "other" for unknown ops so they collapse into a single slice. + let strategy_key = match row.operation.as_str() { + "compress-response" | "compress-toon" | "rewrite-command" + | "compress-schema" => row.operation.clone(), + _ => "other".to_string(), + }; + let entry = grand_strategy_map.entry(strategy_key).or_insert(( + strategy_label.clone(), + 0, + 0, + )); + entry.1 += saved; + entry.2 += compounded; + + items.push(OptimizationItemDto { + id: row.tool_use_id.clone(), + category: category.to_string(), + title: title.to_string(), + strategy, + strategy_label, + before_tokens: row.before_tokens, + after_tokens: row.after_tokens, + saved_tokens: saved, + compounded_saved: compounded, + compounding_turns, + before_summary: format!("原始内容 {} tokens", row.before_tokens), + after_summary: format!("优化后 {} tokens", row.after_tokens), + before_text: row.before_text.clone(), + after_text: row.after_text.clone(), + diff_lines, + }); + } + } + + // FIX(#1): use compounded/total_tokens for both list and detail pages + let savings_rate = if total_tokens > 0 { + session_saved as f64 / total_tokens as f64 * 100.0 + } else { + 0.0 + }; + let compounded_savings_rate = if total_tokens > 0 { + session_compounded_saved as f64 / total_tokens as f64 * 100.0 + } else { + 0.0 + }; + + grand_input += session.total_input_tokens; + grand_output += session.total_output_tokens; + grand_saved += session_saved; + grand_compounded_saved += session_compounded_saved; + grand_tool_saved += session_tool_saved; + grand_mcp_saved += session_mcp_saved; + grand_compounded_tool_saved += session_compounded_tool_saved; + grand_compounded_mcp_saved += session_compounded_mcp_saved; + + resp_sessions.push(SessionSavingsDto { + session_id: session.session_id.clone(), + agent_name: session.agent_name.clone().unwrap_or_default(), + total_input_tokens: session.total_input_tokens, + total_output_tokens: session.total_output_tokens, + total_tokens, + saved_tokens: session_saved, + compounded_saved: session_compounded_saved, + savings_rate, + compounded_savings_rate, + request_count, + tool_saved: session_tool_saved, + mcp_saved: session_mcp_saved, + optimization_items: items, + }); + } + + let grand_total = grand_input + grand_output; + let grand_rate = if grand_total > 0 { + grand_saved as f64 / grand_total as f64 * 100.0 + } else { + 0.0 + }; + let grand_compounded_rate = if grand_total > 0 { + grand_compounded_saved as f64 / grand_total as f64 * 100.0 + } else { + 0.0 + }; + + // FIX(#2): strategy = operation key (for frontend color lookup), + // label = Chinese display name + let strategy_breakdown: Vec = grand_strategy_map + .into_iter() + .map( + |(strategy_key, (label, saved, compounded_saved))| StrategyBreakdown { + strategy: strategy_key, + label, + saved, + compounded_saved, + }, + ) + .collect(); + + HttpResponse::Ok().json(TokenSavingsResponse { + stats_available, + summary: SavingsSummary { + total_input_tokens: grand_input, + total_output_tokens: grand_output, + total_tokens: grand_total, + total_saved_tokens: grand_saved, + total_compounded_saved: grand_compounded_saved, + savings_rate: grand_rate, + compounded_savings_rate: grand_compounded_rate, + total_tool_saved: grand_tool_saved, + total_mcp_saved: grand_mcp_saved, + total_compounded_tool_saved: grand_compounded_tool_saved, + total_compounded_mcp_saved: grand_compounded_mcp_saved, + strategy_breakdown, + }, + sessions: resp_sessions, + }) +} + +// ─── GET /api/token-savings/session/{session_id} ───────────────────────────── + +/// GET /api/token-savings/session/{session_id} +/// +/// Returns token savings detail for a single session. +#[get("/api/token-savings/session/{session_id}")] +pub async fn get_session_savings( + data: web::Data, + path: web::Path, +) -> impl Responder { + let session_id = path.into_inner(); + let db_path = &data.storage_path; + + // FIX(#3): query single session by id instead of full-table scan + let store = match GenAISqliteStore::new_with_path(db_path) { + Ok(s) => s, + Err(e) => { + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})); + } + }; + + let session = match store.get_session_for_savings(&session_id) { + Ok(Some(s)) => s, + Ok(None) => { + return HttpResponse::Ok().json(SessionSavingsDetail { + session_id, + stats_available: false, + total_actual_tokens: 0, + total_compounded_saved: 0, + total_original_tokens: 0, + savings_rate: 0.0, + items: Vec::new(), + }); + } + Err(e) => { + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})); + } + }; + + let total_tokens = session.total_input_tokens + session.total_output_tokens; + let request_count = session.request_count; + + // Step 2: Get turn indices for tool_call_ids + let session_ids = vec![session_id.as_str()]; + let turn_indices = match GenAISqliteStore::new_with_path(db_path) { + Ok(st) => st + .get_tool_call_turn_indices(&session_ids) + .unwrap_or_default(), + Err(_) => std::collections::HashMap::new(), + }; + + // Step 3: Open stats.db + let stats_path = tokenless::default_stats_path(); + let stats_store = TokenlessStatsStore::open_if_exists(&stats_path); + let stats_available = stats_store.is_some(); + + let mut items = Vec::new(); + let mut total_compounded_saved: i64 = 0; + + if let Some(ref store) = stats_store { + let tool_use_ids: Vec<&str> = turn_indices.keys().map(|s| s.as_str()).collect(); + let rows = store.get_stats_by_tool_use_ids(&tool_use_ids); + + for row in &rows { + // Only include rows belonging to this session + let sid = turn_indices + .get(&row.tool_use_id) + .map(|info| info.session_id.as_str()) + .unwrap_or(&row.session_id); + if sid != session_id { + continue; + } + + let saved = row.before_tokens - row.after_tokens; + let category = map_operation_to_category(&row.operation); + let title = map_operation_to_title(&row.operation); + let strategy = row.operation.clone(); + let strategy_label = map_operation_to_strategy_label(&row.operation).to_string(); + + let turn_index = turn_indices + .get(&row.tool_use_id) + .map(|info| info.turn_index) + .unwrap_or(1) as i64; + let compounding_turns = (request_count - turn_index).max(1); + let compounded = saved * compounding_turns; + total_compounded_saved += compounded; + + items.push(OptimizationItemDto { + id: row.tool_use_id.clone(), + category: category.to_string(), + title: title.to_string(), + strategy, + strategy_label, + before_tokens: row.before_tokens, + after_tokens: row.after_tokens, + saved_tokens: saved, + compounded_saved: compounded, + compounding_turns, + before_summary: format!("原始内容 {} tokens", row.before_tokens), + after_summary: format!("优化后 {} tokens", row.after_tokens), + before_text: row.before_text.clone(), + after_text: row.after_text.clone(), + diff_lines: Vec::new(), + }); + } + } + + // FIX(#1): use compounded/total_tokens — consistent with get_token_savings + let savings_rate = if total_tokens > 0 { + total_compounded_saved as f64 / total_tokens as f64 * 100.0 + } else { + 0.0 + }; + + HttpResponse::Ok().json(SessionSavingsDetail { + session_id, + stats_available, + total_actual_tokens: total_tokens, + total_compounded_saved, + total_original_tokens: total_tokens + total_compounded_saved, + savings_rate, + items, + }) +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use actix_web::test as actix_test; + use actix_web::{App, web}; + use std::sync::{Arc, Mutex, RwLock}; + use std::time::Instant; + + // Tests manipulate the HOME env var which is process-global. + // Use a mutex to serialize tests that depend on it. + // allow(clippy::await_holding_lock): intentional — we need the lock held + // for the entire test to prevent parallel env var races. + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + /// Create a temp genai_events.db with test data and return its path. + fn setup_genai_db(dir: &std::path::Path) -> std::path::PathBuf { + let db_path = dir.join("genai_events.db"); + // Use GenAISqliteStore to create proper schema + let store = crate::storage::sqlite::GenAISqliteStore::new_with_path(&db_path).unwrap(); + // Insert test data directly via raw connection + drop(store); + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute( + "INSERT INTO genai_events (event_type, session_id, call_id, agent_name, model, input_tokens, output_tokens, start_timestamp_ns, event_json, tool_call_ids) + VALUES ('llm_call', 'sess-1', 'call-1', 'test-agent', 'gpt-4', 1000, 500, 100000000, '{}', '[\"tc-1\"]')", + [], + ).unwrap(); + conn.execute( + "INSERT INTO genai_events (event_type, session_id, call_id, agent_name, model, input_tokens, output_tokens, start_timestamp_ns, event_json, tool_call_ids) + VALUES ('llm_call', 'sess-1', 'call-2', 'test-agent', 'gpt-4', 800, 400, 200000000, '{}', '[\"tc-2\"]')", + [], + ).unwrap(); + db_path + } + + /// Create a temp stats.db with test data and return its path. + fn setup_stats_db(dir: &std::path::Path) -> std::path::PathBuf { + let stats_dir = dir.join(".tokenless"); + std::fs::create_dir_all(&stats_dir).unwrap(); + let stats_path = stats_dir.join("stats.db"); + let conn = rusqlite::Connection::open(&stats_path).unwrap(); + conn.execute_batch( + "CREATE TABLE stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, + tool_use_id TEXT, + before_tokens INTEGER, + after_tokens INTEGER, + before_text TEXT, + after_text TEXT, + operation TEXT + );", + ) + .unwrap(); + conn.execute( + "INSERT INTO stats (session_id, tool_use_id, before_tokens, after_tokens, before_text, after_text, operation) + VALUES ('sess-1', 'tc-1', 2000, 500, 'long text', 'short', 'compress-response')", + [], + ).unwrap(); + conn.execute( + "INSERT INTO stats (session_id, tool_use_id, before_tokens, after_tokens, before_text, after_text, operation) + VALUES ('sess-1', 'tc-2', 1000, 300, 'schema text', 'mini', 'compress-schema')", + [], + ).unwrap(); + stats_path + } + + fn make_app_state(db_path: std::path::PathBuf) -> AppState { + AppState { + storage_path: db_path, + start_time: Instant::now(), + health_store: Arc::new(RwLock::new(crate::health::HealthStore::default())), + interruption_store: None, + security_observability: crate::server::SecurityObservabilityConfig::default(), + } + } + + // ─── Unit tests for mapping functions ───────────────────────────────── + + #[test] + fn test_map_operation_to_category() { + assert_eq!( + map_operation_to_category("compress-response"), + "mcp_response" + ); + assert_eq!(map_operation_to_category("compress-toon"), "mcp_response"); + assert_eq!(map_operation_to_category("rewrite-command"), "tool_output"); + assert_eq!(map_operation_to_category("compress-schema"), "tool_output"); + assert_eq!(map_operation_to_category("unknown-op"), "tool_output"); + } + + #[test] + fn test_map_operation_to_title() { + assert_eq!(map_operation_to_title("compress-response"), "MCP响应压缩"); + assert_eq!(map_operation_to_title("rewrite-command"), "工具输出优化"); + assert_eq!(map_operation_to_title("compress-schema"), "Schema 压缩"); + assert_eq!(map_operation_to_title("compress-toon"), "TOON 编码"); + assert_eq!(map_operation_to_title("other"), "其他优化"); + } + + #[test] + fn test_map_operation_to_strategy_label() { + assert_eq!( + map_operation_to_strategy_label("compress-schema"), + "Schema 压缩" + ); + assert_eq!( + map_operation_to_strategy_label("compress-response"), + "响应压缩" + ); + assert_eq!( + map_operation_to_strategy_label("rewrite-command"), + "命令重写" + ); + assert_eq!( + map_operation_to_strategy_label("compress-toon"), + "TOON 编码" + ); + assert_eq!(map_operation_to_strategy_label("unknown"), "其他优化"); + } + + // ─── Integration tests for handlers ─────────────────────────────────── + + #[allow(clippy::await_holding_lock)] + #[actix_web::test] + async fn test_get_token_savings_no_stats_db() { + let _lock = ENV_MUTEX.lock().unwrap(); + let orig_home = std::env::var("HOME").ok(); + // When stats.db doesn't exist, handler should return stats_available=false + let tmp = std::env::temp_dir().join(format!("agentsight_test_{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let db_path = setup_genai_db(&tmp); + + // Point HOME to a dir without .tokenless/stats.db + let fake_home = tmp.join("fakehome"); + std::fs::create_dir_all(&fake_home).unwrap(); + unsafe { std::env::set_var("HOME", &fake_home) }; + + let state = make_app_state(db_path); + let app = actix_test::init_service( + App::new() + .app_data(web::Data::new(state)) + .service(get_token_savings), + ) + .await; + + let req = actix_test::TestRequest::get() + .uri("/api/token-savings?start_ns=0&end_ns=9999999999999999") + .to_request(); + let resp = actix_test::call_service(&app, req).await; + assert_eq!(resp.status(), 200); + + let body: serde_json::Value = actix_test::read_body_json(resp).await; + assert_eq!(body["stats_available"], false); + assert!(!body["sessions"].as_array().unwrap().is_empty()); + + // Restore HOME to avoid polluting other tests + match orig_home { + Some(v) => unsafe { std::env::set_var("HOME", v) }, + None => unsafe { std::env::remove_var("HOME") }, + } + let _ = std::fs::remove_dir_all(&tmp); + } + + #[allow(clippy::await_holding_lock)] + #[actix_web::test] + async fn test_get_token_savings_with_stats() { + let _lock = ENV_MUTEX.lock().unwrap(); + let orig_home = std::env::var("HOME").ok(); + let tmp = + std::env::temp_dir().join(format!("agentsight_test_stats_{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let db_path = setup_genai_db(&tmp); + let _stats_path = setup_stats_db(&tmp); + + // Point HOME to tmp so default_stats_path() finds .tokenless/stats.db + unsafe { std::env::set_var("HOME", &tmp) }; + + let state = make_app_state(db_path); + let app = actix_test::init_service( + App::new() + .app_data(web::Data::new(state)) + .service(get_token_savings), + ) + .await; + + let req = actix_test::TestRequest::get() + .uri("/api/token-savings?start_ns=0&end_ns=9999999999999999") + .to_request(); + let resp = actix_test::call_service(&app, req).await; + assert_eq!(resp.status(), 200); + + let body: serde_json::Value = actix_test::read_body_json(resp).await; + assert_eq!(body["stats_available"], true); + // Should have strategy_breakdown + let breakdown = body["summary"]["strategy_breakdown"].as_array().unwrap(); + assert!(!breakdown.is_empty()); + // Check savings were computed + let total_saved = body["summary"]["total_saved_tokens"].as_i64().unwrap(); + assert!(total_saved > 0); + + // Restore HOME + match orig_home { + Some(v) => unsafe { std::env::set_var("HOME", v) }, + None => unsafe { std::env::remove_var("HOME") }, + } + let _ = std::fs::remove_dir_all(&tmp); + } + + #[allow(clippy::await_holding_lock)] + #[actix_web::test] + async fn test_get_session_savings_not_found() { + let _lock = ENV_MUTEX.lock().unwrap(); + let orig_home = std::env::var("HOME").ok(); + let tmp = std::env::temp_dir().join(format!("agentsight_test_sess_{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let db_path = setup_genai_db(&tmp); + + let fake_home = tmp.join("fakehome2"); + std::fs::create_dir_all(&fake_home).unwrap(); + unsafe { std::env::set_var("HOME", &fake_home) }; + + let state = make_app_state(db_path); + let app = actix_test::init_service( + App::new() + .app_data(web::Data::new(state)) + .service(get_session_savings), + ) + .await; + + let req = actix_test::TestRequest::get() + .uri("/api/token-savings/session/nonexistent") + .to_request(); + let resp = actix_test::call_service(&app, req).await; + assert_eq!(resp.status(), 200); + + let body: serde_json::Value = actix_test::read_body_json(resp).await; + assert_eq!(body["stats_available"], false); + assert_eq!(body["session_id"], "nonexistent"); + + // Restore HOME + match orig_home { + Some(v) => unsafe { std::env::set_var("HOME", v) }, + None => unsafe { std::env::remove_var("HOME") }, + } + let _ = std::fs::remove_dir_all(&tmp); + } + + #[allow(clippy::await_holding_lock)] + #[actix_web::test] + async fn test_get_session_savings_with_data() { + let _lock = ENV_MUTEX.lock().unwrap(); + let orig_home = std::env::var("HOME").ok(); + let tmp = + std::env::temp_dir().join(format!("agentsight_test_sess_data_{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let db_path = setup_genai_db(&tmp); + let _stats_path = setup_stats_db(&tmp); + + unsafe { std::env::set_var("HOME", &tmp) }; + + let state = make_app_state(db_path); + let app = actix_test::init_service( + App::new() + .app_data(web::Data::new(state)) + .service(get_session_savings), + ) + .await; + + let req = actix_test::TestRequest::get() + .uri("/api/token-savings/session/sess-1") + .to_request(); + let resp = actix_test::call_service(&app, req).await; + assert_eq!(resp.status(), 200); + + let body: serde_json::Value = actix_test::read_body_json(resp).await; + assert_eq!(body["stats_available"], true); + assert_eq!(body["session_id"], "sess-1"); + let items = body["items"].as_array().unwrap(); + assert!(!items.is_empty()); + // Verify strategy fields are present + assert!(items[0]["strategy"].as_str().is_some()); + assert!(items[0]["strategy_label"].as_str().is_some()); + let compounded = body["total_compounded_saved"].as_i64().unwrap(); + assert!(compounded > 0); + + // Restore HOME + match orig_home { + Some(v) => unsafe { std::env::set_var("HOME", v) }, + None => unsafe { std::env::remove_var("HOME") }, + } + let _ = std::fs::remove_dir_all(&tmp); + } +} diff --git a/src/agentsight/src/storage/sqlite/genai.rs b/src/agentsight/src/storage/sqlite/genai.rs index 287461b03..fba12a844 100644 --- a/src/agentsight/src/storage/sqlite/genai.rs +++ b/src/agentsight/src/storage/sqlite/genai.rs @@ -1046,6 +1046,44 @@ impl GenAISqliteStore { Ok(result) } + /// Query a single session's savings summary by `session_id`. + /// + /// Unlike `list_sessions_for_savings` which scans a time range, + /// this targets the index on `session_id` directly — O(1) lookup. + pub fn get_session_for_savings( + &self, + session_id: &str, + ) -> Result, Box> { + let conn = self.conn.lock().unwrap(); + + let sql = "SELECT session_id, + MAX(agent_name) AS agent_name, + COALESCE(SUM(input_tokens), 0) AS total_input, + COALESCE(SUM(output_tokens), 0) AS total_output, + COUNT(*) AS request_count + FROM genai_events + WHERE event_type = 'llm_call' + AND session_id = ?1 + GROUP BY session_id"; + + let mut stmt = conn.prepare(sql)?; + let mut rows = stmt.query_map(rusqlite::params![session_id], |row| { + Ok(SavingsSessionSummary { + session_id: row.get(0)?, + agent_name: row.get(1)?, + total_input_tokens: row.get(2)?, + total_output_tokens: row.get(3)?, + request_count: row.get(4)?, + }) + })?; + + match rows.next() { + Some(Ok(summary)) => Ok(Some(summary)), + Some(Err(e)) => Err(Box::new(e)), + None => Ok(None), + } + } + /// Get the turn index (1-based) for each llm_call in a session. /// /// Returns a map from `call_id` to its position in the time-ordered
分类 + 节省策略 + 优化前