Skip to main content

agentmux_srv/backend/
agent_context_usage.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Where a running Claude CLI will auto-compact, from its own answer.
5//!
6//! The context meter's countdown used to assume the CLI's default
7//! (window − 33K). The real point moves with `CLAUDE_CODE_AUTO_COMPACT_WINDOW`,
8//! the `autoCompactWindow` setting and `CLAUDE_CODE_DISABLE_1M_CONTEXT`, and
9//! auto-compaction can be off altogether. The CLI says which: the
10//! `get_context_usage` control request (`detail: "summary"`, answered locally
11//! with no inference call) returns `autoCompactThreshold` (absent when off),
12//! `isAutoCompactEnabled`, `maxTokens` (the auto-compact window) and the
13//! model. Verified on CLI 2.1.288:
14//! docs/reports/REPORT_AGENT_PANE_CONTEXT_METER_2026_10_05.md §9.1.
15//!
16//! Asked at the same points as `get_settings` (`agent_runtime.rs`): at spawn
17//! and at every turn boundary, of a control-protocol process only. Published
18//! as the persisted per-pane `agentcontextusage` event.
19
20use serde_json::{json, Value};
21
22/// Prefix of the `request_id` of our `get_context_usage` requests, so the
23/// answer can be told from the answers to anything else.
24pub const CONTEXT_USAGE_REQUEST_PREFIX: &str = "agentmux-context-usage-";
25
26/// What the CLI reported about auto-compaction for its current model.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct AgentContextUsage {
29    /// The model the answer is for, as the CLI names it (a raw id, e.g.
30    /// `claude-haiku-4-5-20251001`).
31    pub model: Option<String>,
32    /// The window auto-compaction is measured against; smaller than the
33    /// model's context window when an override applies.
34    pub auto_compact_window: Option<u64>,
35    /// The prompt size at which the CLI auto-compacts; `None` when it is off.
36    pub auto_compact_threshold: Option<u64>,
37    pub auto_compact_enabled: bool,
38}
39
40/// The `get_context_usage` request. `summary` is answered from local state;
41/// `full` would call the token-count API.
42pub fn context_usage_request_line() -> String {
43    json!({
44        "type": "control_request",
45        "request_id": format!("{CONTEXT_USAGE_REQUEST_PREFIX}{}", uuid::Uuid::new_v4()),
46        "request": { "subtype": "get_context_usage", "detail": "summary" },
47    })
48    .to_string()
49}
50
51/// A positive count no larger than any real window (100M), else `None`.
52fn count(v: Option<&Value>) -> Option<u64> {
53    v.and_then(Value::as_u64)
54        .filter(|n| *n > 0 && *n <= 100_000_000)
55}
56
57/// The usage in a `get_context_usage` answer, or `None` for any other frame,
58/// an error answer (a CLI without the request) or one missing
59/// `isAutoCompactEnabled`. Shape: `{type: control_response, response:
60/// {subtype: success, request_id, response: {model, maxTokens,
61/// autoCompactThreshold?, isAutoCompactEnabled, ...}}}`.
62pub fn context_usage_from_control_response(frame: &Value) -> Option<AgentContextUsage> {
63    if frame.get("type").and_then(Value::as_str) != Some("control_response") {
64        return None;
65    }
66    let resp = frame.get("response")?;
67    if resp.get("subtype").and_then(Value::as_str) != Some("success") {
68        return None;
69    }
70    if !resp
71        .get("request_id")
72        .and_then(Value::as_str)
73        .is_some_and(|id| id.starts_with(CONTEXT_USAGE_REQUEST_PREFIX))
74    {
75        return None;
76    }
77    let body = resp.get("response")?.as_object()?;
78    let enabled = body.get("isAutoCompactEnabled")?.as_bool()?;
79    Some(AgentContextUsage {
80        model: body
81            .get("model")
82            .and_then(Value::as_str)
83            .filter(|m| !m.is_empty())
84            .map(str::to_string),
85        auto_compact_window: count(body.get("maxTokens")),
86        // A threshold reported while disabled means nothing.
87        auto_compact_threshold: if enabled {
88            count(body.get("autoCompactThreshold"))
89        } else {
90            None
91        },
92        auto_compact_enabled: enabled,
93    })
94}
95
96/// The `agentcontextusage` event's payload (`mps_events::AgentContextUsageChanged`). An
97/// unknown value is null, not omitted.
98#[derive(Debug, Clone, PartialEq, serde::Serialize, ts_rs::TS)]
99#[ts(export, export_to = "../../../frontend/types/rpc/")]
100pub struct AgentContextUsageEvent {
101    pub blockid: String,
102    pub model: Option<String>,
103    #[ts(type = "number | null")]
104    pub auto_compact_window: Option<u64>,
105    #[ts(type = "number | null")]
106    pub auto_compact_threshold: Option<u64>,
107    pub auto_compact_enabled: bool,
108}
109
110/// The `agentcontextusage` event data for one pane.
111pub fn agent_context_usage_event(block_id: &str, usage: &AgentContextUsage) -> AgentContextUsageEvent {
112    AgentContextUsageEvent {
113        blockid: block_id.to_string(),
114        model: usage.model.clone(),
115        auto_compact_window: usage.auto_compact_window,
116        auto_compact_threshold: usage.auto_compact_threshold,
117        auto_compact_enabled: usage.auto_compact_enabled,
118    }
119}
120
121/// Publish it, persisted, so a pane that mounts later still gets the latest.
122pub fn publish_agent_context_usage(
123    broker: &crate::backend::mps::Broker,
124    block_id: &str,
125    usage: &AgentContextUsage,
126) {
127    broker.publish_event::<crate::backend::mps_events::AgentContextUsageChanged>(
128        &agent_context_usage_event(block_id, usage),
129        vec![format!("block:{block_id}")],
130        1,
131    );
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    fn answer(request_id: &str, body: Value) -> Value {
139        json!({
140            "type": "control_response",
141            "response": { "subtype": "success", "request_id": request_id, "response": body },
142        })
143    }
144
145    fn ours() -> String {
146        format!("{CONTEXT_USAGE_REQUEST_PREFIX}abc")
147    }
148
149    #[test]
150    fn the_request_is_a_local_summary_with_our_prefix() {
151        let line: Value = serde_json::from_str(&context_usage_request_line()).unwrap();
152        assert_eq!(line["type"], "control_request");
153        assert_eq!(line["request"]["subtype"], "get_context_usage");
154        assert_eq!(line["request"]["detail"], "summary");
155        assert!(line["request_id"]
156            .as_str()
157            .unwrap()
158            .starts_with(CONTEXT_USAGE_REQUEST_PREFIX));
159    }
160
161    #[test]
162    fn reads_the_default_answer() {
163        // CLI 2.1.288, Sonnet 5.5, no overrides.
164        let frame = answer(
165            &ours(),
166            json!({
167                "model": "claude-sonnet-5-5", "maxTokens": 1_000_000, "rawMaxTokens": 1_000_000,
168                "autoCompactThreshold": 967_000, "isAutoCompactEnabled": true,
169                "autocompactSource": "model-default", "totalTokens": 37_475, "categories": [],
170            }),
171        );
172        assert_eq!(
173            context_usage_from_control_response(&frame),
174            Some(AgentContextUsage {
175                model: Some("claude-sonnet-5-5".into()),
176                auto_compact_window: Some(1_000_000),
177                auto_compact_threshold: Some(967_000),
178                auto_compact_enabled: true,
179            })
180        );
181    }
182
183    #[test]
184    fn reads_an_overridden_window() {
185        // CLAUDE_CODE_AUTO_COMPACT_WINDOW=300000.
186        let frame = answer(
187            &ours(),
188            json!({ "model": "claude-sonnet-5-5", "maxTokens": 300_000, "autoCompactThreshold": 267_000,
189                    "isAutoCompactEnabled": true, "autocompactSource": "env" }),
190        );
191        let u = context_usage_from_control_response(&frame).unwrap();
192        assert_eq!(
193            (u.auto_compact_window, u.auto_compact_threshold),
194            (Some(300_000), Some(267_000))
195        );
196    }
197
198    #[test]
199    fn disabled_has_no_threshold() {
200        let frame = answer(
201            &ours(),
202            json!({ "model": "claude-sonnet-5-5", "maxTokens": 1_000_000, "autoCompactThreshold": 967_000,
203                    "isAutoCompactEnabled": false }),
204        );
205        let u = context_usage_from_control_response(&frame).unwrap();
206        assert!(!u.auto_compact_enabled);
207        assert_eq!(u.auto_compact_threshold, None);
208    }
209
210    #[test]
211    fn ignores_other_answers_errors_and_junk() {
212        let body = json!({ "model": "m", "maxTokens": 200_000, "autoCompactThreshold": 167_000, "isAutoCompactEnabled": true });
213        // Someone else's request (get_settings).
214        assert_eq!(
215            context_usage_from_control_response(&answer("agentmux-settings-x", body.clone())),
216            None
217        );
218        // An error answer from a CLI without the request.
219        let err = json!({ "type": "control_response",
220                          "response": { "subtype": "error", "request_id": ours(), "error": "Unsupported" } });
221        assert_eq!(context_usage_from_control_response(&err), None);
222        // Not a control response.
223        assert_eq!(
224            context_usage_from_control_response(&json!({ "type": "result" })),
225            None
226        );
227        // No enabled flag: not an answer we understand.
228        assert_eq!(
229            context_usage_from_control_response(&answer(&ours(), json!({ "maxTokens": 1 }))),
230            None
231        );
232        // Implausible counts are dropped, not trusted.
233        let u = context_usage_from_control_response(&answer(
234            &ours(),
235            json!({ "maxTokens": 0, "autoCompactThreshold": -5, "isAutoCompactEnabled": true }),
236        ))
237        .unwrap();
238        assert_eq!(
239            (u.auto_compact_window, u.auto_compact_threshold),
240            (None, None)
241        );
242    }
243
244    #[test]
245    fn the_event_carries_the_fields_snake_cased() {
246        let u = AgentContextUsage {
247            model: Some("claude-haiku-4-5-20251001".into()),
248            auto_compact_window: Some(200_000),
249            auto_compact_threshold: Some(167_000),
250            auto_compact_enabled: true,
251        };
252        assert_eq!(
253            serde_json::to_value(agent_context_usage_event("blk", &u)).unwrap(),
254            json!({ "blockid": "blk", "model": "claude-haiku-4-5-20251001", "auto_compact_window": 200_000,
255                    "auto_compact_threshold": 167_000, "auto_compact_enabled": true })
256        );
257    }
258}