From 362b2a075f237f8a60fc2010107400e9ac97c478 Mon Sep 17 00:00:00 2001 From: David Fitzsimmons Date: Sun, 26 Jul 2026 23:51:51 -0400 Subject: [PATCH 1/2] feat(desktop): add per-agent usage dashboard Signed-off-by: David Fitzsimmons --- desktop/src-tauri/src/commands/agent_logs.rs | 158 +++++++++++++++++ desktop/src-tauri/src/lib.rs | 1 + desktop/src/features/agents/hooks.ts | 12 ++ .../agents/ui/AgentUsageDashboard.tsx | 164 ++++++++++++++++++ desktop/src/features/agents/ui/AgentsView.tsx | 7 + desktop/src/shared/api/agentUsageTypes.ts | 16 ++ desktop/src/shared/api/tauriAgentUsage.ts | 14 ++ desktop/src/testing/e2eBridge.ts | 2 + 8 files changed, 374 insertions(+) create mode 100644 desktop/src/features/agents/ui/AgentUsageDashboard.tsx create mode 100644 desktop/src/shared/api/agentUsageTypes.ts create mode 100644 desktop/src/shared/api/tauriAgentUsage.ts diff --git a/desktop/src-tauri/src/commands/agent_logs.rs b/desktop/src-tauri/src/commands/agent_logs.rs index 273654e32a..82365238f6 100644 --- a/desktop/src-tauri/src/commands/agent_logs.rs +++ b/desktop/src-tauri/src/commands/agent_logs.rs @@ -1,3 +1,4 @@ +use serde::Serialize; use tauri::{AppHandle, Manager}; use crate::{ @@ -8,6 +9,74 @@ use crate::{ }, }; +const USAGE_LOG_SAMPLE_LINES: usize = 20_000; + +#[derive(Debug, Default, PartialEq, Eq)] +struct ParsedAgentUsage { + prompt_count: u64, + prompt_bytes: u64, + peak_prompt_bytes: u64, + session_start_count: u64, + large_prompt_count: u64, + retry_count: u64, + quota_limit_count: u64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentUsageSummary { + pubkey: String, + name: String, + model: Option, + parallelism: u32, + is_running: bool, + prompt_count: u64, + prompt_bytes: u64, + estimated_prompt_tokens: u64, + peak_prompt_bytes: u64, + session_start_count: u64, + large_prompt_count: u64, + retry_count: u64, + quota_limit_count: u64, +} + +fn parse_u64_field(line: &str, field: &str) -> Option { + let prefix = format!("{field}="); + line.split_whitespace() + .find_map(|part| part.strip_prefix(&prefix)) + .and_then(|value| value.trim_end_matches([',', ';']).parse().ok()) +} + +fn parse_agent_usage(content: &str) -> ParsedAgentUsage { + let mut usage = ParsedAgentUsage::default(); + + for line in content.lines() { + if line.contains("prompt prepared") && !line.contains("large prompt prepared") { + if let Some(bytes) = parse_u64_field(line, "prompt_bytes") { + usage.prompt_count += 1; + usage.prompt_bytes = usage.prompt_bytes.saturating_add(bytes); + usage.peak_prompt_bytes = usage.peak_prompt_bytes.max(bytes); + } + if line.contains("is_new_session=true") { + usage.session_start_count += 1; + } + } + if line.contains("large prompt prepared") { + usage.large_prompt_count += 1; + } + if line.contains("requeueing failed batch with backoff") + || line.contains("requeued for retry") + { + usage.retry_count += 1; + } + if line.contains("provider usage limit reached") { + usage.quota_limit_count += 1; + } + } + + usage +} + #[tauri::command] pub async fn get_managed_agent_log( pubkey: String, @@ -38,3 +107,92 @@ pub async fn get_managed_agent_log( .await .map_err(|e| format!("spawn_blocking failed: {e}"))? } + +/// Return bounded, per-agent prompt-cost proxies from local harness logs. +/// +/// ACP adapters do not all expose exact provider token counts. Prompt bytes +/// are therefore reported as a transparent input-size proxy, with the +/// conventional bytes/4 token estimate clearly labeled as an estimate in the +/// UI. The bounded tail avoids loading unbounded historical logs. +#[tauri::command] +pub async fn get_agent_usage_dashboard(app: AppHandle) -> Result, String> { + tokio::task::spawn_blocking(move || { + let records = { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + load_managed_agents(&app)? + }; + let mut summaries = Vec::new(); + + for record in records.into_iter().filter(|record| { + !record.pubkey.is_empty() && matches!(&record.backend, BackendKind::Local) + }) { + let log_path = managed_agent_log_path(&app, &record.pubkey)?; + let content = read_log_tail(&log_path, USAGE_LOG_SAMPLE_LINES)?; + let usage = parse_agent_usage(&content); + + summaries.push(AgentUsageSummary { + pubkey: record.pubkey, + name: record.name, + model: record.model, + parallelism: record.parallelism, + is_running: record.runtime_pid.is_some(), + prompt_count: usage.prompt_count, + prompt_bytes: usage.prompt_bytes, + estimated_prompt_tokens: usage.prompt_bytes.saturating_add(3) / 4, + peak_prompt_bytes: usage.peak_prompt_bytes, + session_start_count: usage.session_start_count, + large_prompt_count: usage.large_prompt_count, + retry_count: usage.retry_count, + quota_limit_count: usage.quota_limit_count, + }); + } + + summaries.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(summaries) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_agent_usage_summarizes_prompt_cost_and_failures() { + let log = r#" +INFO pool::prompt: prompt prepared prompt_bytes=12000 prompt_blocks=5 is_new_session=true +INFO pool::prompt: prompt prepared prompt_bytes=800 prompt_blocks=2 is_new_session=false +WARN pool::prompt: large prompt prepared prompt_bytes=52000 +WARN requeueing failed batch with backoff retry_count=1 +ERROR dead-lettering batch immediately — provider usage limit reached +"#; + + assert_eq!( + parse_agent_usage(log), + ParsedAgentUsage { + prompt_count: 2, + prompt_bytes: 12_800, + peak_prompt_bytes: 12_000, + session_start_count: 1, + large_prompt_count: 1, + retry_count: 1, + quota_limit_count: 1, + } + ); + } + + #[test] + fn parse_agent_usage_ignores_malformed_prompt_measurements() { + let usage = parse_agent_usage( + "INFO prompt prepared prompt_bytes=unknown is_new_session=true\nINFO unrelated", + ); + assert_eq!(usage.prompt_count, 0); + assert_eq!(usage.prompt_bytes, 0); + assert_eq!(usage.session_start_count, 1); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7dcc5994ae..0873c2c490 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -802,6 +802,7 @@ pub fn run() { set_managed_agent_auto_restart, delete_managed_agent, get_managed_agent_log, + get_agent_usage_dashboard, get_agent_models, discover_agent_models, get_agent_config_surface, diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 122c872e54..3aa41e2b02 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -38,6 +38,7 @@ import { saveCustomHarness, updateManagedAgent, } from "@/shared/api/tauri"; +import { getAgentUsageDashboard } from "@/shared/api/tauriAgentUsage"; import type { HarnessDefinitionInput } from "@/shared/api/tauri"; import { setManagedAgentAutoRestart, @@ -110,6 +111,7 @@ export const personasQueryKey = ["personas"] as const; export const acpRuntimesQueryKey = ["acp-runtimes"] as const; export const acpAuthMethodsQueryKey = ["acp-auth-methods"] as const; export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const; +export const agentUsageDashboardQueryKey = ["agent-usage-dashboard"] as const; export const backendProvidersQueryKey = ["backend-providers"] as const; export const gitBashPrerequisiteQueryKey = ["git-bash-prerequisite"] as const; @@ -898,6 +900,16 @@ export function useManagedAgentLogQuery( }); } +export function useAgentUsageDashboardQuery() { + return useQuery({ + queryKey: agentUsageDashboardQueryKey, + queryFn: getAgentUsageDashboard, + retry: false, + staleTime: 10_000, + refetchInterval: 30_000, + }); +} + export const agentConfigSurfaceQueryKey = (pubkey: string) => ["agent-config-surface", pubkey] as const; diff --git a/desktop/src/features/agents/ui/AgentUsageDashboard.tsx b/desktop/src/features/agents/ui/AgentUsageDashboard.tsx new file mode 100644 index 0000000000..64aac5dd64 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentUsageDashboard.tsx @@ -0,0 +1,164 @@ +import { RefreshCw } from "lucide-react"; + +import { useAgentUsageDashboardQuery } from "@/features/agents/hooks"; +import type { AgentUsageSummary } from "@/shared/api/agentUsageTypes"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/shared/ui/card"; + +const COMPACT_NUMBER = new Intl.NumberFormat("en-US", { + maximumFractionDigits: 1, + notation: "compact", +}); + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function formatPromptEstimate(summary: AgentUsageSummary): string { + if (summary.promptCount === 0) return "—"; + return `≈${COMPACT_NUMBER.format(summary.estimatedPromptTokens)}`; +} + +type AgentUsageDashboardProps = { + onOpenAgent: (pubkey: string) => void; +}; + +export function AgentUsageDashboard({ onOpenAgent }: AgentUsageDashboardProps) { + const usageQuery = useAgentUsageDashboardQuery(); + const summaries = usageQuery.data ?? []; + + return ( + + +
+ Agent usage + + Buzz-side input measurements from each local agent log. Estimated + tokens are prompt bytes ÷ 4, not provider billing totals. + +
+ +
+ + {usageQuery.error instanceof Error ? ( +

+ Could not read agent usage: {usageQuery.error.message} +

+ ) : usageQuery.isLoading ? ( +

Loading usage…

+ ) : summaries.length === 0 ? ( +

+ No local agent instances are available yet. +

+ ) : ( +
+ + + + + + + + + + + + + + + {summaries.map((summary) => ( + + + + + + + + + + + ))} + +
AgentWorkersPromptsEst. inputPrompt bytesPeakRetriesQuota stops
+ +
+ {summary.model ?? "Inherited model"} + {summary.isRunning ? " · running" : " · stopped"} +
+
+ 10 ? "warning" : "secondary" + } + > + {summary.parallelism} + + +
{summary.promptCount.toLocaleString()}
+
+ {summary.sessionStartCount.toLocaleString()} sessions +
+
+ {formatPromptEstimate(summary)} + + {summary.promptCount > 0 + ? formatBytes(summary.promptBytes) + : "—"} + +
+ {summary.promptCount > 0 + ? formatBytes(summary.peakPromptBytes) + : "—"} +
+ {summary.largePromptCount > 0 ? ( +
+ {summary.largePromptCount.toLocaleString()} over 50 KB +
+ ) : null} +
+ {summary.retryCount.toLocaleString()} + + {summary.quotaLimitCount.toLocaleString()} +
+
+ )} + {summaries.length > 0 ? ( +

+ Each row samples at most the latest 20,000 log lines. Provider + adapters do not consistently expose exact input, output, cache, or + billing totals. +

+ ) : null} +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 3d1673c365..a6b5aaab22 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -8,6 +8,7 @@ import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog"; import { AddTeamToChannelDialog } from "./AddTeamToChannelDialog"; import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; import { AgentDialog } from "./AgentDialog"; +import { AgentUsageDashboard } from "./AgentUsageDashboard"; import { PersonaCatalogDialog } from "./PersonaCatalogDialog"; import { PersonaDeleteDialog } from "./PersonaDeleteDialog"; import { PersonaShareDialog } from "./PersonaShareDialog"; @@ -212,6 +213,12 @@ export function AgentsView() { title="Agents" />
+ { + openProfilePanel?.(pubkey); + }} + /> + { + return invokeTauri("get_agent_usage_dashboard"); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 841e6ba83f..2574a4166f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -10991,6 +10991,8 @@ export function maybeInstallE2eTauriMocks() { return handleGetManagedAgentLog( payload as Parameters[0], ); + case "get_agent_usage_dashboard": + return []; case "get_agent_models": return { agentName: "mock-agent", From 073478dbcb1023d219e13fa95c5d17c2e5a1e02d Mon Sep 17 00:00:00 2001 From: David Fitzsimmons Date: Mon, 27 Jul 2026 00:51:13 -0400 Subject: [PATCH 2/2] fix(desktop): use effective agent usage state Signed-off-by: David Fitzsimmons --- desktop/src-tauri/src/commands/agent_logs.rs | 73 ++++++++++++++++---- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_logs.rs b/desktop/src-tauri/src/commands/agent_logs.rs index 82365238f6..a4032903ec 100644 --- a/desktop/src-tauri/src/commands/agent_logs.rs +++ b/desktop/src-tauri/src/commands/agent_logs.rs @@ -1,11 +1,14 @@ +use std::path::PathBuf; + use serde::Serialize; use tauri::{AppHandle, Manager}; use crate::{ app_state::AppState, managed_agents::{ - latest_managed_agent_log_path, load_managed_agents, read_log_tail, BackendKind, - ManagedAgentLogResponse, + build_managed_agent_summary, latest_managed_agent_log_path, load_global_agent_config, + load_managed_agents, load_personas, managed_agent_log_path, managed_agent_runtime_log_path, + read_log_tail, workspace_pair_key, BackendKind, ManagedAgentLogResponse, }, }; @@ -24,6 +27,10 @@ struct ParsedAgentUsage { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] +/// Bounded local usage and effective runtime metadata for one managed agent. +/// +/// Prompt measurements are derived from harness logs and are estimates rather +/// than provider billing data. No prompt or message content is serialized. pub struct AgentUsageSummary { pubkey: String, name: String, @@ -77,6 +84,12 @@ fn parse_agent_usage(content: &str) -> ParsedAgentUsage { usage } +fn select_usage_log_path(pair_path: Option, legacy_path: PathBuf) -> PathBuf { + pair_path + .filter(|path| path.exists()) + .unwrap_or(legacy_path) +} + #[tauri::command] pub async fn get_managed_agent_log( pubkey: String, @@ -117,29 +130,48 @@ pub async fn get_managed_agent_log( #[tauri::command] pub async fn get_agent_usage_dashboard(app: AppHandle) -> Result, String> { tokio::task::spawn_blocking(move || { - let records = { + let agent_contexts = { let state = app.state::(); let _store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - load_managed_agents(&app)? + let records = load_managed_agents(&app)?; + let personas = load_personas(&app).unwrap_or_default(); + let global = load_global_agent_config(&app).unwrap_or_default(); + let runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; + + records + .iter() + .filter(|record| { + !record.pubkey.is_empty() && matches!(&record.backend, BackendKind::Local) + }) + .map(|record| { + let summary = + build_managed_agent_summary(&app, record, &runtimes, &personas, &global)?; + let legacy_log_path = managed_agent_log_path(&app, &record.pubkey)?; + let pair_log_path = workspace_pair_key(&app, record) + .and_then(|key| managed_agent_runtime_log_path(&app, &key).ok()); + let log_path = select_usage_log_path(pair_log_path, legacy_log_path); + Ok((summary, log_path)) + }) + .collect::, String>>()? }; let mut summaries = Vec::new(); - for record in records.into_iter().filter(|record| { - !record.pubkey.is_empty() && matches!(&record.backend, BackendKind::Local) - }) { - let log_path = managed_agent_log_path(&app, &record.pubkey)?; + for (summary, log_path) in agent_contexts { let content = read_log_tail(&log_path, USAGE_LOG_SAMPLE_LINES)?; let usage = parse_agent_usage(&content); summaries.push(AgentUsageSummary { - pubkey: record.pubkey, - name: record.name, - model: record.model, - parallelism: record.parallelism, - is_running: record.runtime_pid.is_some(), + pubkey: summary.pubkey, + name: summary.name, + model: summary.model, + parallelism: summary.parallelism, + is_running: summary.status == "running", prompt_count: usage.prompt_count, prompt_bytes: usage.prompt_bytes, estimated_prompt_tokens: usage.prompt_bytes.saturating_add(3) / 4, @@ -195,4 +227,19 @@ ERROR dead-lettering batch immediately — provider usage limit reached assert_eq!(usage.prompt_bytes, 0); assert_eq!(usage.session_start_count, 1); } + + #[test] + fn usage_log_prefers_existing_pair_scope_and_falls_back_to_legacy() { + let temp = tempfile::tempdir().unwrap(); + let pair = temp.path().join("pair.log"); + let legacy = temp.path().join("legacy.log"); + + assert_eq!( + select_usage_log_path(Some(pair.clone()), legacy.clone()), + legacy + ); + + std::fs::write(&pair, "pair usage").unwrap(); + assert_eq!(select_usage_log_path(Some(pair.clone()), legacy), pair); + } }