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
209 changes: 207 additions & 2 deletions desktop/src-tauri/src/commands/agent_logs.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,95 @@
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,
},
};

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")]
/// 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 {
Comment thread
fitz2882 marked this conversation as resolved.
pubkey: String,
name: String,
model: Option<String>,
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<u64> {
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
}

fn select_usage_log_path(pair_path: Option<PathBuf>, 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,
Expand Down Expand Up @@ -38,3 +120,126 @@ 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<Vec<AgentUsageSummary>, String> {
tokio::task::spawn_blocking(move || {
let agent_contexts = {
let state = app.state::<AppState>();
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
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::<Result<Vec<_>, String>>()?
};
let mut summaries = Vec::new();

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: 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,
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);
}

#[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);
}
}
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions desktop/src/features/agents/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate usage data after agent mutations

When a user creates, updates, starts, stops, or deletes an agent while this view is mounted, the existing mutation callbacks invalidate only the managed/relay-agent queries and never this new query key. Because each usage row also contains the agent's name, model, worker count, and running state, the dashboard can show obsolete metadata—or a deleted agent—for up to the 30-second polling interval unless the user manually refreshes it; include this query in the managed-agent mutation invalidation paths.

Useful? React with 👍 / 👎.

export const backendProvidersQueryKey = ["backend-providers"] as const;
export const gitBashPrerequisiteQueryKey = ["git-bash-prerequisite"] as const;

Expand Down Expand Up @@ -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;

Expand Down
Loading