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.
342/// Attachment-aware history estimate shared with rollover's reduction check.
343/// Does not charge megabytes of image base64 as ordinary prompt tokens.
344pub(crate) fn estimate_history(messages: &[SharedMessage]) -> u64 {
345    messages.iter().fold(0u64, |sum, message| {
346        sum.saturating_add(estimate_message(&TokenEstimator::Conservative, message))
347            .saturating_add(PER_MESSAGE_FRAMING_TOKENS)
348    })
349}
350
351fn estimate_message(estimator: &TokenEstimator, msg: &Value) -> u64 {
352    let Some(blocks) = msg["content"].as_array() else {
353        return estimate_serialized(estimator, msg);
354    };
355    // Zero drift for messages without images: charge exactly as before.
356    if !blocks.iter().any(|b| {
357        is_base64_image(b)
358            || b["content"]
359                .as_array()
360                .is_some_and(|a| a.iter().any(is_base64_image))
361    }) {
362        return estimate_serialized(estimator, msg);
363    }
364    let mut total = 0u64;
365    for block in blocks {
366        if is_base64_image(block) {
367            total += IMAGE_TOKEN_ESTIMATE;
368            continue;
369        }
370        if block["type"] == "tool_result" {
371            if let Some(inner) = block["content"].as_array() {
372                total += 8; // wrapper keys: type, tool_use_id
373                for b in inner {
374                    total += if is_base64_image(b) {
375                        IMAGE_TOKEN_ESTIMATE
376                    } else {
377                        estimate_serialized(estimator, b)
378                    };
379                }
380                continue;
381            }
382        }
383        total += estimate_serialized(estimator, block);
384    }
385    total
386}
387
388fn is_base64_image(b: &Value) -> bool {
389    b["type"] == "image" && b["source"]["type"] == "base64"
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use serde_json::json;
396    use std::sync::Arc;
397
398    fn history_tokens_of(messages: &[SharedMessage]) -> u64 {
399        let inputs = ContextBudgetInputs {
400            model: "claude-sonnet-4-6",
401            provider_window: 200_000,
402            system_prompt: None,
403            tools_schema: &[],
404            messages,
405            skill_contents: &[],
406            memory_contents: &[],
407            thinking_budget_tokens: 0,
408            next_tool_result_bytes: 0,
409            output_reserve_tokens: 0,
410        };
411        assess(&inputs).breakdown.history_tokens
412    }
413
414    fn image_block(len: usize) -> Value {
415        json!({
416            "type": "image",
417            "source": {"type": "base64", "media_type": "image/png", "data": "A".repeat(len)}
418        })
419    }
420
421    #[test]
422    fn image_block_in_tool_result_charged_flat() {
423        let messages: Vec<SharedMessage> = vec![Arc::new(json!({
424            "role": "user",
425            "content": [{
426                "type": "tool_result",
427                "tool_use_id": "toolu_1",
428                "content": [
429                    {"type": "text", "text": "Image: /tmp/x.png (1024x1536, image/png, 2292 KB)"},
430                    image_block(3_000_000),
431                ]
432            }]
433        }))];
434        let tokens = history_tokens_of(&messages);
435        assert!(tokens >= IMAGE_TOKEN_ESTIMATE, "{tokens}");
436        assert!(tokens <= IMAGE_TOKEN_ESTIMATE + 200, "{tokens}");
437        assert!(tokens < 10_000, "{tokens}");
438    }
439
440    #[test]
441    fn user_message_image_charged_flat() {
442        let messages: Vec<SharedMessage> = vec![Arc::new(json!({
443            "role": "user",
444            "content": [
445                {"type": "text", "text": "what is this?"},
446                image_block(3_000_000),
447            ]
448        }))];
449        let tokens = history_tokens_of(&messages);
450        assert!(tokens >= IMAGE_TOKEN_ESTIMATE, "{tokens}");
451        assert!(tokens <= IMAGE_TOKEN_ESTIMATE + 200, "{tokens}");
452    }
453
454    #[test]
455    fn string_content_unchanged() {
456        let msg = json!({"role": "user", "content": "plain string content, no blocks here"});
457        let estimator = estimator_for_model("claude-sonnet-4-6");
458        assert_eq!(
459            estimate_message(&estimator, &msg),
460            estimate_serialized(&estimator, &msg)
461        );
462    }
463
464    #[test]
465    fn array_content_without_images_unchanged() {
466        let estimator = estimator_for_model("claude-sonnet-4-6");
467        for msg in [
468            json!({"role": "assistant", "content": [
469                {"type": "text", "text": "let me look"},
470                {"type": "tool_use", "id": "toolu_1", "name": "read", "input": {"path": "x"}}
471            ]}),
472            json!({"role": "user", "content": [
473                {"type": "tool_result", "tool_use_id": "toolu_1", "content": "1\tfoo\n2\tbar"}
474            ]}),
475            json!({"role": "user", "content": [
476                {"type": "tool_result", "tool_use_id": "toolu_1", "content": [
477                    {"type": "text", "text": "nested text only"}
478                ]}
479            ]}),
480        ] {
481            assert_eq!(
482                estimate_message(&estimator, &msg),
483                estimate_serialized(&estimator, &msg),
484                "{msg}"
485            );
486        }
487    }
488
489    #[test]
490    fn non_image_blocks_still_charged_by_payload() {
491        // A big text block must NOT get the flat image discount.
492        let msg = json!({"role": "user", "content": [
493            {"type": "text", "text": "A".repeat(100_000)}
494        ]});
495        let estimator = estimator_for_model("claude-sonnet-4-6");
496        assert!(estimate_message(&estimator, &msg) > 10_000);
497    }
498
499    #[test]
500    fn ascii_is_charged_at_the_dense_bpe_rate() {
501        // 25 ASCII chars → ceil(25 / 2.5) = 10 tokens.
502        assert_eq!(conservative_token_estimate(&"a".repeat(25)), 10);
503        assert_eq!(conservative_token_estimate(""), 0);
504    }
505
506    #[test]
507    fn non_ascii_is_charged_per_utf8_byte() {
508        // CJK: 3 bytes/char → 3 tokens each.
509        assert_eq!(conservative_token_estimate("你好"), 6);
510        // 4-byte emoji scalar → 4 tokens.
511        assert_eq!(conservative_token_estimate("😀"), 4);
512        // 2-byte Cyrillic → 2 tokens each.
513        assert_eq!(conservative_token_estimate("да"), 4);
514    }
515
516    #[test]
517    fn tiny_window_never_underflows_and_fails_toward_compaction() {
518        let messages: Vec<SharedMessage> = (0..MIN_COMPACTION_MESSAGES)
519            .map(|i| Arc::new(json!({"role": "user", "content": format!("m{i}")})) as SharedMessage)
520            .collect();
521        let assessment = assess(&ContextBudgetInputs {
522            model: "claude-sonnet-4-6",
523            provider_window: 10,
524            system_prompt: None,
525            tools_schema: &[],
526            messages: &messages,
527            skill_contents: &[],
528            memory_contents: &[],
529            thinking_budget_tokens: 1_000,
530            next_tool_result_bytes: 0,
531            output_reserve_tokens: 0,
532        });
533        assert_eq!(assessment.budget_tokens(), 0);
534        assert_eq!(assessment.remaining_tokens(), 0);
535        assert!(assessment.should_compact());
536    }
537
538    /// I1 (CP-12 review): Anthropic-wire requests spend thinking from
539    /// `max_tokens` — the reserve model must count thinking exactly once.
540    #[test]
541    fn anthropic_wire_counts_thinking_exactly_once_inside_the_output_reserve() {
542        let messages: Vec<SharedMessage> =
543            vec![Arc::new(json!({"role": "user", "content": "hi"})) as SharedMessage];
544        let window = 200_000;
545        let margin = window * SAFETY_MARGIN_PERCENT / 100;
546        let inputs = ContextBudgetInputs {
547            model: "claude-sonnet-4-6",
548            provider_window: window,
549            system_prompt: None,
550            tools_schema: &[],
551            messages: &messages,
552            skill_contents: &[],
553            memory_contents: &[],
554            thinking_budget_tokens: 8_000,
555            next_tool_result_bytes: 0,
556            output_reserve_tokens: 64_000,
557        };
558        let assessment = assess(&inputs);
559        assert_eq!(
560            assessment.reserves.thinking_accounting,
561            ThinkingAccounting::InsideOutputBudget
562        );
563        assert_eq!(
564            assessment.reserves.thinking_tokens, 0,
565            "thinking must not be reserved a second time on the Anthropic wire"
566        );
567        assert_eq!(assessment.reserves.output_tokens, 64_000);
568        assert_eq!(
569            assessment.budget_tokens(),
570            window - (64_000 + margin),
571            "budget must subtract the output/thinking envelope exactly once"
572        );
573
574        // A thinking budget larger than the requested output governs the
575        // envelope (the wire requires max_tokens >= budget_tokens).
576        let big_thinking = ContextBudgetInputs {
577            thinking_budget_tokens: 100_000,
578            ..inputs
579        };
580        let assessment = assess(&big_thinking);
581        assert_eq!(assessment.reserves.output_tokens, 100_000);
582        assert_eq!(assessment.reserves.thinking_tokens, 0);
583    }
584
585    /// OpenAI Responses/Chat wires bill reasoning inside the output cap too.
586    #[test]
587    fn openai_wires_account_reasoning_inside_the_output_reserve() {
588        for model in ["openai-codex/gpt-5.2-codex", "xai-auth/grok-4"] {
589            let accounting = thinking_accounting_for_model(model);
590            // xai-auth requires a cataloged model; skip honestly if the
591            // catalog drops it rather than asserting a stale fixture.
592            if model.starts_with("xai-auth") && accounting == ThinkingAccounting::SeparateFromOutput
593            {
594                continue;
595            }
596            assert_eq!(
597                accounting,
598                ThinkingAccounting::InsideOutputBudget,
599                "{model}"
600            );
601        }
602    }
603
604    /// Gemini budgets thinking separately from response output; unroutable
605    /// models fall back to the conservative separate-reserve model.
606    #[test]
607    fn gemini_and_unknown_models_reserve_thinking_separately() {
608        let messages: Vec<SharedMessage> =
609            vec![Arc::new(json!({"role": "user", "content": "hi"})) as SharedMessage];
610        let window = 200_000;
611        let margin = window * SAFETY_MARGIN_PERCENT / 100;
612        for model in ["google-gemini/gemini-2.5-pro", "totally/unroutable-model"] {
613            let assessment = assess(&ContextBudgetInputs {
614                model,
615                provider_window: window,
616                system_prompt: None,
617                tools_schema: &[],
618                messages: &messages,
619                skill_contents: &[],
620                memory_contents: &[],
621                thinking_budget_tokens: 8_000,
622                next_tool_result_bytes: 0,
623                output_reserve_tokens: 64_000,
624            });
625            assert_eq!(
626                assessment.reserves.thinking_accounting,
627                ThinkingAccounting::SeparateFromOutput,
628                "{model}"
629            );
630            assert_eq!(assessment.reserves.thinking_tokens, 8_000, "{model}");
631            assert_eq!(assessment.reserves.output_tokens, 64_000, "{model}");
632            assert_eq!(
633                assessment.budget_tokens(),
634                window - (8_000 + 64_000 + margin),
635                "{model}: separate wires reserve both envelopes"
636            );
637        }
638    }
639
640    /// `Runtime::assess_context` is the SAME calculation as `assess` on the
641    /// runtime's own segments — the Runtime surface must never fork the math.
642    #[tokio::test]
643    async fn runtime_assessment_matches_engine_calculation() {
644        let mut runtime = crate::Runtime::new_headless();
645        runtime.set_system_prompt("shared system prompt for budget parity".into());
646        runtime.set_context_window(Some(200_000));
647
648        let messages: Vec<SharedMessage> = vec![
649            Arc::new(json!({"role": "user", "content": "hello 你好 😀"})),
650            Arc::new(json!({"role": "assistant", "content": [
651                {"type": "text", "text": "fn main() { println!(\"hi\"); }"}
652            ]})),
653        ];
654
655        let via_runtime = runtime.assess_context(&messages).await;
656
657        let system = runtime.effective_system_prompt().await;
658        let schema = runtime.tools_shared().read().await.tools_schema();
659        let direct = assess(&ContextBudgetInputs {
660            model: runtime.model(),
661            provider_window: runtime.context_window(),
662            system_prompt: system.as_deref(),
663            tools_schema: &schema,
664            messages: &messages,
665            skill_contents: &[],
666            memory_contents: &[],
667            thinking_budget_tokens: runtime.thinking_budget() as u64,
668            next_tool_result_bytes: runtime.max_tool_output() as u64,
669            output_reserve_tokens: via_runtime.reserves.output_tokens,
670        });
671
672        assert_eq!(
673            via_runtime, direct,
674            "Runtime surface must not fork the budget math"
675        );
676        assert!(
677            via_runtime.reserves.output_tokens > 0,
678            "output reserve must be model-derived"
679        );
680        assert!(
681            via_runtime.breakdown.system_tokens > 0,
682            "actual system segment must be accounted"
683        );
684    }
685
686    /// Build a synthetic, in-bounds recall contribution for the runtime's
687    /// own project identity (task A7 budget-wiring tests).
688    fn synthetic_memory_contribution(
689        rendered: &str,
690    ) -> crate::runtime::memory_context::MemoryContextContribution {
691        use crate::runtime::memory_context as mc;
692        use agent_core::BoundedText;
693        mc::MemoryContextContribution {
694            schema: mc::ContributionSchemaVersion::parse("contribution/1").expect("valid schema"),
695            provider_id: mc::ContextProviderId::parse("axel-memory").expect("valid provider"),
696            project_id: crate::runtime::memory_project_id(),
697            records: vec![mc::MemoryContributionRecord {
698                memory_id: mc::MemoryId::parse("mem-0001").expect("valid memory id"),
699                source: mc::MemorySource::ChatHistory,
700                timestamp: std::time::SystemTime::UNIX_EPOCH,
701                rank_reason: vec![mc::RankReason::ExactTopic],
702                sensitivity: agent_core::core::disclosure::DisclosureClass::ModelVisible,
703                retention: mc::RetentionClass::Standard,
704                content: BoundedText::new("the project uses session-scoped authorization", 2048),
705                truncated: false,
706                supersedes: None,
707            }],
708            rendered: BoundedText::new(rendered, 16 * 1024),
709            accounting: mc::ContributionAccounting::default(),
710        }
711    }
712
713    /// Task A7 (spec §10.3): a held recall contribution is charged EXACTLY
714    /// to the `memory_tokens` breakdown lane — `used_tokens`/`remaining_tokens`
715    /// move by exactly the estimated memory tokens, every other breakdown
716    /// lane is unchanged, and the typed reserves (thinking, tool-result,
717    /// output, safety margin) are bit-for-bit identical with and without the
718    /// contribution: memory competes for budget headroom only, NEVER for
719    /// reserves. Clearing the contribution restores the assessment
720    /// bit-for-bit.
721    #[tokio::test]
722    async fn memory_contribution_charges_only_the_memory_lane_and_never_reserves() {
723        let mut runtime = crate::Runtime::new_headless();
724        runtime.set_system_prompt("system prompt for the memory budget test".into());
725        runtime.set_context_window(Some(200_000));
726
727        let messages: Vec<SharedMessage> = vec![
728            Arc::new(json!({"role": "user", "content": "what auth model do we use?"})),
729            Arc::new(json!({"role": "assistant", "content": "let me check"})),
730        ];
731
732        let without = runtime.assess_context(&messages).await;
733        assert_eq!(without.breakdown.memory_tokens, 0);
734
735        let rendered = "[Axel memory — lower-authority project data; verify before relying]\n\
736                        1. mem-0001 — Decision — the project uses session-scoped \
737                        authorization rather than persisted grants.";
738        runtime
739            .hold_memory_contribution(synthetic_memory_contribution(rendered))
740            .expect("in-bounds contribution for the runtime's own project is accepted");
741        let with = runtime.assess_context(&messages).await;
742
743        let expected_memory = conservative_token_estimate(rendered);
744        assert!(expected_memory > 0, "test contribution must be non-empty");
745        assert_eq!(
746            with.breakdown.memory_tokens, expected_memory,
747            "memory lane must carry exactly the estimated rendered tokens"
748        );
749
750        // Reserves: bit-for-bit identical. Memory never touches thinking,
751        // tool-result, output, or safety-margin reserves.
752        assert_eq!(
753            with.reserves, without.reserves,
754            "reserves must be COMPLETELY unaffected by memory content"
755        );
756        assert_eq!(
757            with.budget_tokens(),
758            without.budget_tokens(),
759            "the admissible budget (window minus reserves) must not move"
760        );
761
762        // Every non-memory breakdown lane is unchanged.
763        assert_eq!(
764            with.breakdown.system_tokens,
765            without.breakdown.system_tokens
766        );
767        assert_eq!(
768            with.breakdown.tool_schema_tokens,
769            without.breakdown.tool_schema_tokens
770        );
771        assert_eq!(
772            with.breakdown.history_tokens,
773            without.breakdown.history_tokens
774        );
775        assert_eq!(
776            with.breakdown.framing_tokens,
777            without.breakdown.framing_tokens
778        );
779        assert_eq!(with.breakdown.skill_tokens, without.breakdown.skill_tokens);
780        assert_eq!(with.history_messages, without.history_messages);
781
782        // Usage moves by exactly the memory estimate — in both directions.
783        assert_eq!(with.used_tokens(), without.used_tokens() + expected_memory);
784        assert_eq!(
785            with.remaining_tokens(),
786            without.remaining_tokens() - expected_memory
787        );
788
789        // should_compact still keys off the SAME budget, now with memory
790        // usage included (not asserted to flip here — the contribution is
791        // far smaller than the window; the invariant is the inputs).
792        assert_eq!(
793            with.should_compact(),
794            with.history_messages >= MIN_COMPACTION_MESSAGES
795                && with.used_tokens() >= with.budget_tokens()
796        );
797
798        // Clearing the held segment restores the assessment bit-for-bit —
799        // the no-contribution path is byte-identical to before task A7.
800        runtime.clear_memory_contribution();
801        let cleared = runtime.assess_context(&messages).await;
802        assert_eq!(
803            cleared, without,
804            "no held contribution ⇒ assessment identical to the empty lane"
805        );
806    }
807
808    /// Task A7 fail-closed gate (spec §5.2): a contribution whose project
809    /// identity does not match the host's is refused at hold time and never
810    /// reaches the budget lane.
811    #[tokio::test]
812    async fn foreign_project_contribution_is_refused_and_never_budgeted() {
813        use crate::runtime::memory_context as mc;
814        let mut runtime = crate::Runtime::new_headless();
815        runtime.set_context_window(Some(200_000));
816
817        let mut contribution = synthetic_memory_contribution("foreign project memory");
818        contribution.project_id =
819            mc::ProjectId::parse("project-not-this-host").expect("valid project id");
820        assert_eq!(
821            runtime.hold_memory_contribution(contribution),
822            Err(mc::MemoryContextError::ContributionProjectMismatch)
823        );
824
825        let messages: Vec<SharedMessage> =
826            vec![Arc::new(json!({"role": "user", "content": "hi"})) as SharedMessage];
827        let assessment = runtime.assess_context(&messages).await;
828        assert_eq!(
829            assessment.breakdown.memory_tokens, 0,
830            "a refused contribution must never be budgeted"
831        );
832    }
833}