dashboard: derive ctx badge thresholds from the model context window

This commit is contained in:
iris 2026-05-21 22:52:32 +02:00 committed by Mara
parent cbd4b71322
commit 4a27ef7304
4 changed files with 122 additions and 26 deletions

View file

@ -55,6 +55,15 @@ pub struct ContainerView {
/// the "which agent is close to the window?" dashboard glance.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ctx_tokens: Option<u64>,
/// Context-window size (tokens) for the model this agent ran on its
/// most recent turn — the model name from the last turn-stats row
/// resolved against the host's per-model `contextWindowTokens`
/// config. Lets the dashboard derive the ctx badge thresholds
/// (75% / 50% of the window, matching the harness compaction
/// watermarks) instead of hardcoding them. `None` when the agent
/// has no turns yet or no config key matches the model. (issue #66)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window_tokens: Option<u64>,
/// True while the harness is parked after an API rate-limit response.
/// Detected via the sentinel file `{state_dir}/hyperhive-rate-limited`
/// that the harness writes in `Bus::emit_status("rate_limited")` and
@ -103,7 +112,11 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
.broker
.count_pending_reminders_for(reminder_recipient)
.unwrap_or(0);
let ctx_tokens = read_last_ctx_tokens(&logical);
let last_turn = read_last_turn(&logical);
let ctx_tokens = last_turn.as_ref().map(|(toks, _)| *toks);
let context_window_tokens = last_turn
.as_ref()
.and_then(|(_, model)| resolve_ctx_window(model, &coord.context_window_tokens));
let rate_limited = is_rate_limited(&logical);
let extra_links = read_dashboard_links(&logical);
out.push(ContainerView {
@ -117,6 +130,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
deployed_sha,
pending_reminders,
ctx_tokens,
context_window_tokens,
rate_limited,
extra_links,
});
@ -158,16 +172,16 @@ fn is_rate_limited(name: &str) -> bool {
.exists()
}
/// Read the most recent completed turn's context-window size (prompt
/// tokens) from the agent's turn-stats `SQLite`. Returns `None` when
/// the file is absent or has no rows. Best-effort — any DB error
/// silently yields `None` so a missing/corrupt file never blocks
/// `build_all`.
/// Read the agent's most recent completed turn from its turn-stats
/// `SQLite`: the context-window size (prompt tokens) and the model name.
/// Returns `None` when the file is absent or has no rows. Best-effort
/// — any database error silently yields `None` so a missing or
/// corrupt file never blocks `build_all`.
///
/// Context tokens are the sum of `last_input_tokens`, `last_cache_read_input_tokens`,
/// and `last_cache_creation_input_tokens`, mirroring
/// `hive_ag3nt::events::TokenUsage::context_tokens`.
fn read_last_ctx_tokens(name: &str) -> Option<u64> {
/// Context tokens sum the prompt-side fields (`last_input_tokens`,
/// `last_cache_read_input_tokens`, `last_cache_creation_input_tokens`),
/// mirroring `hive_ag3nt::events::TokenUsage::context_tokens`.
fn read_last_turn(name: &str) -> Option<(u64, String)> {
let path = Coordinator::agent_notes_dir(name).join("hyperhive-turn-stats.sqlite");
let conn = Connection::open_with_flags(
&path,
@ -175,13 +189,29 @@ fn read_last_ctx_tokens(name: &str) -> Option<u64> {
)
.ok()?;
conn.query_row(
"SELECT last_input_tokens + last_cache_read_input_tokens + last_cache_creation_input_tokens \
"SELECT last_input_tokens + last_cache_read_input_tokens + last_cache_creation_input_tokens, model \
FROM turn_stats ORDER BY started_at DESC LIMIT 1",
[],
|row| row.get::<_, i64>(0),
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
)
.ok()
.and_then(|v| u64::try_from(v).ok())
.and_then(|(toks, model)| Some((u64::try_from(toks).ok()?, model)))
}
/// Resolve a model name to its context-window size using the host's
/// per-model `contextWindowTokens` config. Mirrors the harness's
/// `events::context_window_tokens` substring match: the first config
/// key (lowercased, non-empty) that is a substring of the lowercased
/// model name wins. `None` when nothing matches.
fn resolve_ctx_window(model: &str, per_model: &HashMap<String, u64>) -> Option<u64> {
let m = model.to_ascii_lowercase();
per_model
.iter()
.find(|(key, _)| {
let k = key.to_ascii_lowercase();
!k.is_empty() && m.contains(&k)
})
.map(|(_, &tokens)| tokens)
}
/// Map of `agent-<n>` → locked sha from meta's flake.lock. Used to
@ -223,3 +253,48 @@ fn read_meta_locked_revs() -> HashMap<String, String> {
}
out
}
#[cfg(test)]
mod tests {
use super::resolve_ctx_window;
use std::collections::HashMap;
fn cfg() -> HashMap<String, u64> {
[
("haiku".to_owned(), 200_000),
("sonnet".to_owned(), 1_000_000),
("opus".to_owned(), 1_000_000),
]
.into_iter()
.collect()
}
#[test]
fn resolves_family_substring() {
assert_eq!(resolve_ctx_window("claude-3-5-haiku-20241022", &cfg()), Some(200_000));
assert_eq!(resolve_ctx_window("claude-sonnet-4-5", &cfg()), Some(1_000_000));
assert_eq!(resolve_ctx_window("claude-opus-4-1", &cfg()), Some(1_000_000));
}
#[test]
fn resolution_is_case_insensitive() {
assert_eq!(resolve_ctx_window("Claude-Sonnet-4", &cfg()), Some(1_000_000));
}
#[test]
fn unknown_model_yields_none() {
assert_eq!(resolve_ctx_window("some-other-llm", &cfg()), None);
}
#[test]
fn empty_config_yields_none() {
assert_eq!(resolve_ctx_window("claude-3-5-haiku", &HashMap::new()), None);
}
#[test]
fn empty_key_is_skipped() {
let mut m = HashMap::new();
m.insert(String::new(), 999);
assert_eq!(resolve_ctx_window("claude-3-5-haiku", &m), None);
}
}