Skip to main content

agent_engine/runtime/
context.rs

1//! Task 29 — centralized request-aware context budgeting (spec §9.1).
2//!
3//! The engine — not individual frontends — computes the effective context of
4//! the NEXT provider request from the segments that will actually be sent:
5//!
6//! - actual effective system segments;
7//! - exposed tool schemas;
8//! - conversation history plus protocol framing;
9//! - loaded skill and memory content;
10//!
11//! and carves typed reserves out of the provider window before dispatch:
12//!
13//! - reasoning/thinking reserve;
14//! - next likely tool-result reserve;
15//! - requested output reserve;
16//! - a documented safety margin of [`SAFETY_MARGIN_PERCENT`] (spec requires
17//!   10–15%).
18//!
19//! Frontends consume [`ContextAssessment::should_compact`] — no per-frontend
20//! token math is allowed on the trigger path (enforced by the phase 5
21//! source-scan test).
22//!
23//! ## Estimation strategy
24//!
25//! Provider tokenizers plug in through [`estimator_for_model`] when they are
26//! available in-tree. None is today, so every model resolves to the
27//! CONSERVATIVE estimator, which must never overstate remaining capacity:
28//!
29//! - ASCII is charged at 1 token per 2.5 characters — the realistic BPE
30//!   worst case for high-entropy ASCII (hashes, base64, minified JSON), and
31//!   a deliberate ~1.6× overcount for English prose;
32//! - non-ASCII is charged at 1 token per UTF-8 byte — the byte-fallback
33//!   worst case, covering CJK (3 bytes/char), emoji scalars (4 bytes), and
34//!   two-byte scripts without ever understating.
35//!
36//! The estimator therefore errs toward EARLIER compaction, never toward
37//! provider exhaustion; the safety margin absorbs residual framing drift.
38
39use serde_json::Value;
40
41use crate::SharedMessage;
42
43/// Documented pre-dispatch safety margin, as a percentage of the provider
44/// window (spec §9.1: target at least 10–15% reserved capacity).
45pub const SAFETY_MARGIN_PERCENT: u64 = 15;
46
47/// Minimum history length before compaction is meaningful. A freshly
48/// compacted session (summary + acknowledgement) sits below this gate, which
49/// is what prevents recompaction loops without frontend-local bookkeeping.
50pub const MIN_COMPACTION_MESSAGES: usize = 4;
51
52/// Flat charge per base64 image block. Anthropic bills ≈ w·h/750 tokens
53/// AFTER its 1568-px / ~1.15 MP downscale, so 1,600 is the per-image
54/// ceiling (Goose uses the same figure; gemini-cli uses 3,000; Codex
55/// subtracts the payload and adds a ~7 KB estimate). Serialized base64 at
56/// 2.5 chars/token would charge ~1.9 M tokens for a 3.5 MiB image and trip
57/// compaction every turn.
58pub const IMAGE_TOKEN_ESTIMATE: u64 = 1_600;
59
60/// Provider-side per-message framing charge (role tags, message boundaries)
61/// on top of the serialized content itself.
62const PER_MESSAGE_FRAMING_TOKENS: u64 = 4;
63
64/// Tool-result bytes are converted to a token reserve at this many bytes per
65/// token — matching the ASCII charge rate of the conservative estimator, so
66/// the reserve and the usage accounting agree.
67const TOOL_RESULT_BYTES_PER_TOKEN: u64 = 3;
68
69/// Conservative token estimate for a text segment. See the module docs for
70/// the exact charging rule and why it never understates.
71pub fn conservative_token_estimate(text: &str) -> u64 {
72    // Scaled integer accumulation in fifths of a token: ASCII chars cost 2
73    // fifths (1 token per 2.5 chars); non-ASCII chars cost 5 fifths per
74    // UTF-8 byte (1 token per byte).
75    let mut fifths: u64 = 0;
76    for c in text.chars() {
77        if c.is_ascii() {
78            fifths += 2;
79        } else {
80            fifths += 5 * c.len_utf8() as u64;
81        }
82    }
83    fifths.div_ceil(5)
84}
85
86/// Token-estimation seam. Provider tokenizers register here per model when
87/// available; the conservative estimator is the mandatory fallback.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum TokenEstimator {
90    /// No exact tokenizer available — conservative charging (never
91    /// overstates remaining capacity).
92    Conservative,
93}
94
95impl TokenEstimator {
96    pub fn estimate(&self, text: &str) -> u64 {
97        match self {
98            TokenEstimator::Conservative => conservative_token_estimate(text),
99        }
100    }
101}
102
103/// Resolve the estimator for a model. No provider tokenizer crate is linked
104/// today, so every model — Anthropic, OpenAI, Google, unknown — uses the
105/// conservative estimator (spec §9.1: tokenizers where available,
106/// conservative estimators otherwise).
107pub fn estimator_for_model(_model: &str) -> TokenEstimator {
108    TokenEstimator::Conservative
109}
110
111/// Everything the budget calculation needs about the NEXT request. Callers
112/// pass the segments they will actually send; the engine never guesses.
113#[derive(Debug, Clone, Copy)]
114pub struct ContextBudgetInputs<'a> {
115    /// Model the request targets (selects the estimator).
116    pub model: &'a str,
117    /// Effective provider context window in tokens (override-aware).
118    pub provider_window: u64,
119    /// The actual effective system prompt, if any.
120    pub system_prompt: Option<&'a str>,
121    /// The EXPOSED tool schema set for this request (post-projection).
122    pub tools_schema: &'a [Value],
123    /// Conversation history exactly as it will be sent.
124    pub messages: &'a [SharedMessage],
125    /// Skill bodies loaded into the request outside system/history.
126    pub skill_contents: &'a [&'a str],
127    /// Memory bodies loaded into the request outside system/history.
128    pub memory_contents: &'a [&'a str],
129    /// Reasoning/thinking reserve in tokens.
130    pub thinking_budget_tokens: u64,
131    /// Byte cap of the next likely tool result (converted to a token
132    /// reserve at [`TOOL_RESULT_BYTES_PER_TOKEN`]).
133    pub next_tool_result_bytes: u64,
134    /// Requested output reserve (the request's `max_tokens`).
135    pub output_reserve_tokens: u64,
136}
137
138/// Typed per-segment usage accounting.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub struct ContextBreakdown {
141    pub system_tokens: u64,
142    pub tool_schema_tokens: u64,
143    pub history_tokens: u64,
144    pub framing_tokens: u64,
145    pub skill_tokens: u64,
146    pub memory_tokens: u64,
147}
148
149impl ContextBreakdown {
150    pub fn total(&self) -> u64 {
151        self.system_tokens
152            + self.tool_schema_tokens
153            + self.history_tokens
154            + self.framing_tokens
155            + self.skill_tokens
156            + self.memory_tokens
157    }
158}
159
160/// How a provider's wire accounts reasoning/thinking tokens against the
161/// request's output budget (I1, CP-12 review). The reserve model must count
162/// thinking exactly once, and provider differences are represented
163/// explicitly instead of being summed blindly.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum ThinkingAccounting {
166    /// Thinking spends from the request's output cap: Anthropic messages
167    /// (`thinking.budget_tokens < max_tokens`), OpenAI Responses/Chat and
168    /// Codex wires (reasoning tokens count toward `max_output_tokens` /
169    /// `max_completion_tokens`). Reserve ONE envelope:
170    /// `max(output_reserve, thinking_budget)`.
171    InsideOutputBudget,
172    /// Thinking is budgeted separately from response output (Gemini
173    /// `thinkingBudget`), or the wire is unknown — reserve both envelopes,
174    /// which errs toward earlier compaction, never toward exhaustion.
175    SeparateFromOutput,
176}
177
178/// Resolve the thinking-accounting semantics for a model from its wire
179/// protocol. Unroutable models use the conservative separate-reserve model.
180pub fn thinking_accounting_for_model(model: &str) -> ThinkingAccounting {
181    use crate::runtime::openai::WireProtocol;
182    match crate::runtime::openai::resolve_route(model).map(|route| route.wire) {
183        Some(
184            WireProtocol::AnthropicMessages
185            | WireProtocol::OpenAiChatCompletions
186            | WireProtocol::OpenAiResponses
187            | WireProtocol::CodexResponses,
188        ) => ThinkingAccounting::InsideOutputBudget,
189        Some(WireProtocol::GoogleGeminiCodeAssist) | None => ThinkingAccounting::SeparateFromOutput,
190    }
191}
192
193/// Typed reserves carved out of the provider window before any context is
194/// admitted.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub struct ContextReserves {
197    /// Thinking tokens reserved IN ADDITION to the output envelope. Zero on
198    /// wires where thinking already spends from the output cap.
199    pub thinking_tokens: u64,
200    pub tool_result_tokens: u64,
201    /// The request's output envelope. On [`ThinkingAccounting::InsideOutputBudget`]
202    /// wires this is `max(output_reserve, thinking_budget)` — the wire
203    /// requires the output cap to cover thinking, so the larger governs.
204    pub output_tokens: u64,
205    pub safety_margin_tokens: u64,
206    /// The wire semantics the thinking/output split was computed under.
207    pub thinking_accounting: ThinkingAccounting,
208}
209
210impl ContextReserves {
211    pub fn total(&self) -> u64 {
212        self.thinking_tokens
213            + self.tool_result_tokens
214            + self.output_tokens
215            + self.safety_margin_tokens
216    }
217}
218
219/// One request-aware budget assessment. Pure data — frontends read the
220/// decision, they never recompute it.
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub struct ContextAssessment {
223    pub provider_window: u64,
224    pub breakdown: ContextBreakdown,
225    pub reserves: ContextReserves,
226    pub history_messages: usize,
227}
228
229impl ContextAssessment {
230    /// Total conservative usage across every request segment.
231    pub fn used_tokens(&self) -> u64 {
232        self.breakdown.total()
233    }
234
235    /// Tokens the context may occupy after all reserves: window minus
236    /// reserves, floored at zero (a window smaller than the reserves has no
237    /// admissible context at all).
238    pub fn budget_tokens(&self) -> u64 {
239        self.provider_window.saturating_sub(self.reserves.total())
240    }
241
242    /// Remaining admissible capacity before the trigger fires.
243    pub fn remaining_tokens(&self) -> u64 {
244        self.budget_tokens().saturating_sub(self.used_tokens())
245    }
246
247    /// The single cross-frontend compaction trigger: usage reached the
248    /// budget AND there is enough history for a summary to fold.
249    pub fn should_compact(&self) -> bool {
250        self.history_messages >= MIN_COMPACTION_MESSAGES
251            && self.used_tokens() >= self.budget_tokens()
252    }
253}
254
255/// Compute the request-aware context assessment. This is THE budget
256/// calculation — `Runtime::assess_context` and every frontend trigger path
257/// route through here.
258pub fn assess(inputs: &ContextBudgetInputs<'_>) -> ContextAssessment {
259    let estimator = estimator_for_model(inputs.model);
260
261    let system_tokens = inputs
262        .system_prompt
263        .map(|s| estimator.estimate(s))
264        .unwrap_or(0);
265
266    // Schemas and history are charged on their SERIALIZED form: JSON keys,
267    // quoting, and escaping are bytes the provider tokenizes too.
268    let tool_schema_tokens: u64 = inputs
269        .tools_schema
270        .iter()
271        .map(|schema| estimate_serialized(&estimator, schema))
272        .sum();
273
274    let history_tokens: u64 = inputs
275        .messages
276        .iter()
277        .map(|msg| estimate_message(&estimator, msg))
278        .sum();
279    let framing_tokens = PER_MESSAGE_FRAMING_TOKENS * inputs.messages.len() as u64;
280
281    let skill_tokens: u64 = inputs
282        .skill_contents
283        .iter()
284        .map(|s| estimator.estimate(s))
285        .sum();
286    let memory_tokens: u64 = inputs
287        .memory_contents
288        .iter()
289        .map(|s| estimator.estimate(s))
290        .sum();
291
292    ContextAssessment {
293        provider_window: inputs.provider_window,
294        breakdown: ContextBreakdown {
295            system_tokens,
296            tool_schema_tokens,
297            history_tokens,
298            framing_tokens,
299            skill_tokens,
300            memory_tokens,
301        },
302        reserves: {
303            let accounting = thinking_accounting_for_model(inputs.model);
304            let (thinking_tokens, output_tokens) = match accounting {
305                ThinkingAccounting::InsideOutputBudget => (
306                    0,
307                    inputs
308                        .output_reserve_tokens
309                        .max(inputs.thinking_budget_tokens),
310                ),
311                ThinkingAccounting::SeparateFromOutput => {
312                    (inputs.thinking_budget_tokens, inputs.output_reserve_tokens)
313                }
314            };
315            ContextReserves {
316                thinking_tokens,
317                tool_result_tokens: inputs
318                    .next_tool_result_bytes
319                    .div_ceil(TOOL_RESULT_BYTES_PER_TOKEN),
320                output_tokens,
321                safety_margin_tokens: inputs.provider_window * SAFETY_MARGIN_PERCENT / 100,
322                thinking_accounting: accounting,
323            }
324        },
325        history_messages: inputs.messages.len(),
326    }
327}
328
329fn estimate_serialized(estimator: &TokenEstimator, value: &Value) -> u64 {
330    match serde_json::to_string(value) {
331        Ok(s) => estimator.estimate(&s),
332        // Serialization of an in-memory Value cannot realistically fail;
333        // if it ever does, fail toward compaction rather than exhaustion.
334        Err(_) => u64::MAX / 1024,
335    }
336}
337
338/// Like `estimate_serialized` but charges base64 image blocks at a flat
339/// `IMAGE_TOKEN_ESTIMATE` instead of by payload length. Walks
340/// `content[]` and nested `tool_result.content[]`; every other block is
341/// still charged on its serialized form.
342fn estimate_message(estimator: &TokenEstimator, msg: &Value) -> u64 {
343    let Some(blocks) = msg["content"].as_array() else {
344        return estimate_serialized(estimator, msg);
345    };
346    // Zero drift for messages without images: charge exactly as before.
347    if !blocks.iter().any(|b| {
348        is_base64_image(b) || b["content"].as_array().is_some_and(|a| a.iter().any(is_base64_image))
349    }) {
350        return estimate_serialized(estimator, msg);
351    }
352    let mut total = 0u64;
353    for block in blocks {
354        if is_base64_image(block) {
355            total += IMAGE_TOKEN_ESTIMATE;
356            continue;
357        }
358        if block["type"] == "tool_result" {
359            if let Some(inner) = block["content"].as_array() {
360                total += 8; // wrapper keys: type, tool_use_id
361                for b in inner {
362                    total += if is_base64_image(b) {
363                        IMAGE_TOKEN_ESTIMATE
364                    } else {
365                        estimate_serialized(estimator, b)
366                    };
367                }
368                continue;
369            }
370        }
371        total += estimate_serialized(estimator, block);
372    }
373    total
374}
375
376fn is_base64_image(b: &Value) -> bool {
377    b["type"] == "image" && b["source"]["type"] == "base64"
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use serde_json::json;
384    use std::sync::Arc;
385
386    fn history_tokens_of(messages: &[SharedMessage]) -> u64 {
387        let inputs = ContextBudgetInputs {
388            model: "claude-sonnet-4-6",
389            provider_window: 200_000,
390            system_prompt: None,
391            tools_schema: &[],
392            messages,
393            skill_contents: &[],
394            memory_contents: &[],
395            thinking_budget_tokens: 0,
396            next_tool_result_bytes: 0,
397            output_reserve_tokens: 0,
398        };
399        assess(&inputs).breakdown.history_tokens
400    }
401
402    fn image_block(len: usize) -> Value {
403        json!({
404            "type": "image",
405            "source": {"type": "base64", "media_type": "image/png", "data": "A".repeat(len)}
406        })
407    }
408
409    #[test]
410    fn image_block_in_tool_result_charged_flat() {
411        let messages: Vec<SharedMessage> = vec![Arc::new(json!({
412            "role": "user",
413            "content": [{
414                "type": "tool_result",
415                "tool_use_id": "toolu_1",
416                "content": [
417                    {"type": "text", "text": "Image: /tmp/x.png (1024x1536, image/png, 2292 KB)"},
418                    image_block(3_000_000),
419                ]
420            }]
421        }))];
422        let tokens = history_tokens_of(&messages);
423        assert!(tokens >= IMAGE_TOKEN_ESTIMATE, "{tokens}");
424        assert!(tokens <= IMAGE_TOKEN_ESTIMATE + 200, "{tokens}");
425        assert!(tokens < 10_000, "{tokens}");
426    }
427
428    #[test]
429    fn user_message_image_charged_flat() {
430        let messages: Vec<SharedMessage> = vec![Arc::new(json!({
431            "role": "user",
432            "content": [
433                {"type": "text", "text": "what is this?"},
434                image_block(3_000_000),
435            ]
436        }))];
437        let tokens = history_tokens_of(&messages);
438        assert!(tokens >= IMAGE_TOKEN_ESTIMATE, "{tokens}");
439        assert!(tokens <= IMAGE_TOKEN_ESTIMATE + 200, "{tokens}");
440    }
441
442    #[test]
443    fn string_content_unchanged() {
444        let msg = json!({"role": "user", "content": "plain string content, no blocks here"});
445        let estimator = estimator_for_model("claude-sonnet-4-6");
446        assert_eq!(
447            estimate_message(&estimator, &msg),
448            estimate_serialized(&estimator, &msg)
449        );
450    }
451
452    #[test]
453    fn array_content_without_images_unchanged() {
454        let estimator = estimator_for_model("claude-sonnet-4-6");
455        for msg in [
456            json!({"role": "assistant", "content": [
457                {"type": "text", "text": "let me look"},
458                {"type": "tool_use", "id": "toolu_1", "name": "read", "input": {"path": "x"}}
459            ]}),
460            json!({"role": "user", "content": [
461                {"type": "tool_result", "tool_use_id": "toolu_1", "content": "1\tfoo\n2\tbar"}
462            ]}),
463            json!({"role": "user", "content": [
464                {"type": "tool_result", "tool_use_id": "toolu_1", "content": [
465                    {"type": "text", "text": "nested text only"}
466                ]}
467            ]}),
468        ] {
469            assert_eq!(
470                estimate_message(&estimator, &msg),
471                estimate_serialized(&estimator, &msg),
472                "{msg}"
473            );
474        }
475    }
476
477    #[test]
478    fn non_image_blocks_still_charged_by_payload() {
479        // A big text block must NOT get the flat image discount.
480        let msg = json!({"role": "user", "content": [
481            {"type": "text", "text": "A".repeat(100_000)}
482        ]});
483        let estimator = estimator_for_model("claude-sonnet-4-6");
484        assert!(estimate_message(&estimator, &msg) > 10_000);
485    }
486
487    #[test]
488    fn ascii_is_charged_at_the_dense_bpe_rate() {
489        // 25 ASCII chars → ceil(25 / 2.5) = 10 tokens.
490        assert_eq!(conservative_token_estimate(&"a".repeat(25)), 10);
491        assert_eq!(conservative_token_estimate(""), 0);
492    }
493
494    #[test]
495    fn non_ascii_is_charged_per_utf8_byte() {
496        // CJK: 3 bytes/char → 3 tokens each.
497        assert_eq!(conservative_token_estimate("你好"), 6);
498        // 4-byte emoji scalar → 4 tokens.
499        assert_eq!(conservative_token_estimate("😀"), 4);
500        // 2-byte Cyrillic → 2 tokens each.
501        assert_eq!(conservative_token_estimate("да"), 4);
502    }
503
504    #[test]
505    fn tiny_window_never_underflows_and_fails_toward_compaction() {
506        let messages: Vec<SharedMessage> = (0..MIN_COMPACTION_MESSAGES)
507            .map(|i| Arc::new(json!({"role": "user", "content": format!("m{i}")})) as SharedMessage)
508            .collect();
509        let assessment = assess(&ContextBudgetInputs {
510            model: "claude-sonnet-4-6",
511            provider_window: 10,
512            system_prompt: None,
513            tools_schema: &[],
514            messages: &messages,
515            skill_contents: &[],
516            memory_contents: &[],
517            thinking_budget_tokens: 1_000,
518            next_tool_result_bytes: 0,
519            output_reserve_tokens: 0,
520        });
521        assert_eq!(assessment.budget_tokens(), 0);
522        assert_eq!(assessment.remaining_tokens(), 0);
523        assert!(assessment.should_compact());
524    }
525
526    /// I1 (CP-12 review): Anthropic-wire requests spend thinking from
527    /// `max_tokens` — the reserve model must count thinking exactly once.
528    #[test]
529    fn anthropic_wire_counts_thinking_exactly_once_inside_the_output_reserve() {
530        let messages: Vec<SharedMessage> =
531            vec![Arc::new(json!({"role": "user", "content": "hi"})) as SharedMessage];
532        let window = 200_000;
533        let margin = window * SAFETY_MARGIN_PERCENT / 100;
534        let inputs = ContextBudgetInputs {
535            model: "claude-sonnet-4-6",
536            provider_window: window,
537            system_prompt: None,
538            tools_schema: &[],
539            messages: &messages,
540            skill_contents: &[],
541            memory_contents: &[],
542            thinking_budget_tokens: 8_000,
543            next_tool_result_bytes: 0,
544            output_reserve_tokens: 64_000,
545        };
546        let assessment = assess(&inputs);
547        assert_eq!(
548            assessment.reserves.thinking_accounting,
549            ThinkingAccounting::InsideOutputBudget
550        );
551        assert_eq!(
552            assessment.reserves.thinking_tokens, 0,
553            "thinking must not be reserved a second time on the Anthropic wire"
554        );
555        assert_eq!(assessment.reserves.output_tokens, 64_000);
556        assert_eq!(
557            assessment.budget_tokens(),
558            window - (64_000 + margin),
559            "budget must subtract the output/thinking envelope exactly once"
560        );
561
562        // A thinking budget larger than the requested output governs the
563        // envelope (the wire requires max_tokens >= budget_tokens).
564        let big_thinking = ContextBudgetInputs {
565            thinking_budget_tokens: 100_000,
566            ..inputs
567        };
568        let assessment = assess(&big_thinking);
569        assert_eq!(assessment.reserves.output_tokens, 100_000);
570        assert_eq!(assessment.reserves.thinking_tokens, 0);
571    }
572
573    /// OpenAI Responses/Chat wires bill reasoning inside the output cap too.
574    #[test]
575    fn openai_wires_account_reasoning_inside_the_output_reserve() {
576        for model in ["openai-codex/gpt-5.2-codex", "xai-auth/grok-4"] {
577            let accounting = thinking_accounting_for_model(model);
578            // xai-auth requires a cataloged model; skip honestly if the
579            // catalog drops it rather than asserting a stale fixture.
580            if model.starts_with("xai-auth") && accounting == ThinkingAccounting::SeparateFromOutput
581            {
582                continue;
583            }
584            assert_eq!(
585                accounting,
586                ThinkingAccounting::InsideOutputBudget,
587                "{model}"
588            );
589        }
590    }
591
592    /// Gemini budgets thinking separately from response output; unroutable
593    /// models fall back to the conservative separate-reserve model.
594    #[test]
595    fn gemini_and_unknown_models_reserve_thinking_separately() {
596        let messages: Vec<SharedMessage> =
597            vec![Arc::new(json!({"role": "user", "content": "hi"})) as SharedMessage];
598        let window = 200_000;
599        let margin = window * SAFETY_MARGIN_PERCENT / 100;
600        for model in ["google-gemini/gemini-2.5-pro", "totally/unroutable-model"] {
601            let assessment = assess(&ContextBudgetInputs {
602                model,
603                provider_window: window,
604                system_prompt: None,
605                tools_schema: &[],
606                messages: &messages,
607                skill_contents: &[],
608                memory_contents: &[],
609                thinking_budget_tokens: 8_000,
610                next_tool_result_bytes: 0,
611                output_reserve_tokens: 64_000,
612            });
613            assert_eq!(
614                assessment.reserves.thinking_accounting,
615                ThinkingAccounting::SeparateFromOutput,
616                "{model}"
617            );
618            assert_eq!(assessment.reserves.thinking_tokens, 8_000, "{model}");
619            assert_eq!(assessment.reserves.output_tokens, 64_000, "{model}");
620            assert_eq!(
621                assessment.budget_tokens(),
622                window - (8_000 + 64_000 + margin),
623                "{model}: separate wires reserve both envelopes"
624            );
625        }
626    }
627
628    /// `Runtime::assess_context` is the SAME calculation as `assess` on the
629    /// runtime's own segments — the Runtime surface must never fork the math.
630    #[tokio::test]
631    async fn runtime_assessment_matches_engine_calculation() {
632        let mut runtime = crate::Runtime::new_headless();
633        runtime.set_system_prompt("shared system prompt for budget parity".into());
634        runtime.set_context_window(Some(200_000));
635
636        let messages: Vec<SharedMessage> = vec![
637            Arc::new(json!({"role": "user", "content": "hello 你好 😀"})),
638            Arc::new(json!({"role": "assistant", "content": [
639                {"type": "text", "text": "fn main() { println!(\"hi\"); }"}
640            ]})),
641        ];
642
643        let via_runtime = runtime.assess_context(&messages).await;
644
645        let system = runtime.effective_system_prompt().await;
646        let schema = runtime.tools_shared().read().await.tools_schema();
647        let direct = assess(&ContextBudgetInputs {
648            model: runtime.model(),
649            provider_window: runtime.context_window(),
650            system_prompt: system.as_deref(),
651            tools_schema: &schema,
652            messages: &messages,
653            skill_contents: &[],
654            memory_contents: &[],
655            thinking_budget_tokens: runtime.thinking_budget() as u64,
656            next_tool_result_bytes: runtime.max_tool_output() as u64,
657            output_reserve_tokens: via_runtime.reserves.output_tokens,
658        });
659
660        assert_eq!(
661            via_runtime, direct,
662            "Runtime surface must not fork the budget math"
663        );
664        assert!(
665            via_runtime.reserves.output_tokens > 0,
666            "output reserve must be model-derived"
667        );
668        assert!(
669            via_runtime.breakdown.system_tokens > 0,
670            "actual system segment must be accounted"
671        );
672    }
673
674    /// Build a synthetic, in-bounds recall contribution for the runtime's
675    /// own project identity (task A7 budget-wiring tests).
676    fn synthetic_memory_contribution(
677        rendered: &str,
678    ) -> crate::runtime::memory_context::MemoryContextContribution {
679        use crate::runtime::memory_context as mc;
680        use agent_core::BoundedText;
681        mc::MemoryContextContribution {
682            schema: mc::ContributionSchemaVersion::parse("contribution/1").expect("valid schema"),
683            provider_id: mc::ContextProviderId::parse("axel-memory").expect("valid provider"),
684            project_id: crate::runtime::memory_project_id(),
685            records: vec![mc::MemoryContributionRecord {
686                memory_id: mc::MemoryId::parse("mem-0001").expect("valid memory id"),
687                source: mc::MemorySource::ChatHistory,
688                timestamp: std::time::SystemTime::UNIX_EPOCH,
689                rank_reason: vec![mc::RankReason::ExactTopic],
690                sensitivity: agent_core::core::disclosure::DisclosureClass::ModelVisible,
691                retention: mc::RetentionClass::Standard,
692                content: BoundedText::new("the project uses session-scoped authorization", 2048),
693                truncated: false,
694                supersedes: None,
695            }],
696            rendered: BoundedText::new(rendered, 16 * 1024),
697            accounting: mc::ContributionAccounting::default(),
698        }
699    }
700
701    /// Task A7 (spec §10.3): a held recall contribution is charged EXACTLY
702    /// to the `memory_tokens` breakdown lane — `used_tokens`/`remaining_tokens`
703    /// move by exactly the estimated memory tokens, every other breakdown
704    /// lane is unchanged, and the typed reserves (thinking, tool-result,
705    /// output, safety margin) are bit-for-bit identical with and without the
706    /// contribution: memory competes for budget headroom only, NEVER for
707    /// reserves. Clearing the contribution restores the assessment
708    /// bit-for-bit.
709    #[tokio::test]
710    async fn memory_contribution_charges_only_the_memory_lane_and_never_reserves() {
711        let mut runtime = crate::Runtime::new_headless();
712        runtime.set_system_prompt("system prompt for the memory budget test".into());
713        runtime.set_context_window(Some(200_000));
714
715        let messages: Vec<SharedMessage> = vec![
716            Arc::new(json!({"role": "user", "content": "what auth model do we use?"})),
717            Arc::new(json!({"role": "assistant", "content": "let me check"})),
718        ];
719
720        let without = runtime.assess_context(&messages).await;
721        assert_eq!(without.breakdown.memory_tokens, 0);
722
723        let rendered = "[Axel memory — lower-authority project data; verify before relying]\n\
724                        1. mem-0001 — Decision — the project uses session-scoped \
725                        authorization rather than persisted grants.";
726        runtime
727            .hold_memory_contribution(synthetic_memory_contribution(rendered))
728            .expect("in-bounds contribution for the runtime's own project is accepted");
729        let with = runtime.assess_context(&messages).await;
730
731        let expected_memory = conservative_token_estimate(rendered);
732        assert!(expected_memory > 0, "test contribution must be non-empty");
733        assert_eq!(
734            with.breakdown.memory_tokens, expected_memory,
735            "memory lane must carry exactly the estimated rendered tokens"
736        );
737
738        // Reserves: bit-for-bit identical. Memory never touches thinking,
739        // tool-result, output, or safety-margin reserves.
740        assert_eq!(
741            with.reserves, without.reserves,
742            "reserves must be COMPLETELY unaffected by memory content"
743        );
744        assert_eq!(
745            with.budget_tokens(),
746            without.budget_tokens(),
747            "the admissible budget (window minus reserves) must not move"
748        );
749
750        // Every non-memory breakdown lane is unchanged.
751        assert_eq!(
752            with.breakdown.system_tokens,
753            without.breakdown.system_tokens
754        );
755        assert_eq!(
756            with.breakdown.tool_schema_tokens,
757            without.breakdown.tool_schema_tokens
758        );
759        assert_eq!(
760            with.breakdown.history_tokens,
761            without.breakdown.history_tokens
762        );
763        assert_eq!(
764            with.breakdown.framing_tokens,
765            without.breakdown.framing_tokens
766        );
767        assert_eq!(with.breakdown.skill_tokens, without.breakdown.skill_tokens);
768        assert_eq!(with.history_messages, without.history_messages);
769
770        // Usage moves by exactly the memory estimate — in both directions.
771        assert_eq!(with.used_tokens(), without.used_tokens() + expected_memory);
772        assert_eq!(
773            with.remaining_tokens(),
774            without.remaining_tokens() - expected_memory
775        );
776
777        // should_compact still keys off the SAME budget, now with memory
778        // usage included (not asserted to flip here — the contribution is
779        // far smaller than the window; the invariant is the inputs).
780        assert_eq!(
781            with.should_compact(),
782            with.history_messages >= MIN_COMPACTION_MESSAGES
783                && with.used_tokens() >= with.budget_tokens()
784        );
785
786        // Clearing the held segment restores the assessment bit-for-bit —
787        // the no-contribution path is byte-identical to before task A7.
788        runtime.clear_memory_contribution();
789        let cleared = runtime.assess_context(&messages).await;
790        assert_eq!(
791            cleared, without,
792            "no held contribution ⇒ assessment identical to the empty lane"
793        );
794    }
795
796    /// Task A7 fail-closed gate (spec §5.2): a contribution whose project
797    /// identity does not match the host's is refused at hold time and never
798    /// reaches the budget lane.
799    #[tokio::test]
800    async fn foreign_project_contribution_is_refused_and_never_budgeted() {
801        use crate::runtime::memory_context as mc;
802        let mut runtime = crate::Runtime::new_headless();
803        runtime.set_context_window(Some(200_000));
804
805        let mut contribution = synthetic_memory_contribution("foreign project memory");
806        contribution.project_id =
807            mc::ProjectId::parse("project-not-this-host").expect("valid project id");
808        assert_eq!(
809            runtime.hold_memory_contribution(contribution),
810            Err(mc::MemoryContextError::ContributionProjectMismatch)
811        );
812
813        let messages: Vec<SharedMessage> =
814            vec![Arc::new(json!({"role": "user", "content": "hi"})) as SharedMessage];
815        let assessment = runtime.assess_context(&messages).await;
816        assert_eq!(
817            assessment.breakdown.memory_tokens, 0,
818            "a refused contribution must never be budgeted"
819        );
820    }
821}