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/// Provider-side per-message framing charge (role tags, message boundaries)
53/// on top of the serialized content itself.
54const PER_MESSAGE_FRAMING_TOKENS: u64 = 4;
55
56/// Tool-result bytes are converted to a token reserve at this many bytes per
57/// token — matching the ASCII charge rate of the conservative estimator, so
58/// the reserve and the usage accounting agree.
59const TOOL_RESULT_BYTES_PER_TOKEN: u64 = 3;
60
61/// Conservative token estimate for a text segment. See the module docs for
62/// the exact charging rule and why it never understates.
63pub fn conservative_token_estimate(text: &str) -> u64 {
64    // Scaled integer accumulation in fifths of a token: ASCII chars cost 2
65    // fifths (1 token per 2.5 chars); non-ASCII chars cost 5 fifths per
66    // UTF-8 byte (1 token per byte).
67    let mut fifths: u64 = 0;
68    for c in text.chars() {
69        if c.is_ascii() {
70            fifths += 2;
71        } else {
72            fifths += 5 * c.len_utf8() as u64;
73        }
74    }
75    fifths.div_ceil(5)
76}
77
78/// Token-estimation seam. Provider tokenizers register here per model when
79/// available; the conservative estimator is the mandatory fallback.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum TokenEstimator {
82    /// No exact tokenizer available — conservative charging (never
83    /// overstates remaining capacity).
84    Conservative,
85}
86
87impl TokenEstimator {
88    pub fn estimate(&self, text: &str) -> u64 {
89        match self {
90            TokenEstimator::Conservative => conservative_token_estimate(text),
91        }
92    }
93}
94
95/// Resolve the estimator for a model. No provider tokenizer crate is linked
96/// today, so every model — Anthropic, OpenAI, Google, unknown — uses the
97/// conservative estimator (spec §9.1: tokenizers where available,
98/// conservative estimators otherwise).
99pub fn estimator_for_model(_model: &str) -> TokenEstimator {
100    TokenEstimator::Conservative
101}
102
103/// Everything the budget calculation needs about the NEXT request. Callers
104/// pass the segments they will actually send; the engine never guesses.
105#[derive(Debug, Clone, Copy)]
106pub struct ContextBudgetInputs<'a> {
107    /// Model the request targets (selects the estimator).
108    pub model: &'a str,
109    /// Effective provider context window in tokens (override-aware).
110    pub provider_window: u64,
111    /// The actual effective system prompt, if any.
112    pub system_prompt: Option<&'a str>,
113    /// The EXPOSED tool schema set for this request (post-projection).
114    pub tools_schema: &'a [Value],
115    /// Conversation history exactly as it will be sent.
116    pub messages: &'a [SharedMessage],
117    /// Skill bodies loaded into the request outside system/history.
118    pub skill_contents: &'a [&'a str],
119    /// Memory bodies loaded into the request outside system/history.
120    pub memory_contents: &'a [&'a str],
121    /// Reasoning/thinking reserve in tokens.
122    pub thinking_budget_tokens: u64,
123    /// Byte cap of the next likely tool result (converted to a token
124    /// reserve at [`TOOL_RESULT_BYTES_PER_TOKEN`]).
125    pub next_tool_result_bytes: u64,
126    /// Requested output reserve (the request's `max_tokens`).
127    pub output_reserve_tokens: u64,
128}
129
130/// Typed per-segment usage accounting.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub struct ContextBreakdown {
133    pub system_tokens: u64,
134    pub tool_schema_tokens: u64,
135    pub history_tokens: u64,
136    pub framing_tokens: u64,
137    pub skill_tokens: u64,
138    pub memory_tokens: u64,
139}
140
141impl ContextBreakdown {
142    pub fn total(&self) -> u64 {
143        self.system_tokens
144            + self.tool_schema_tokens
145            + self.history_tokens
146            + self.framing_tokens
147            + self.skill_tokens
148            + self.memory_tokens
149    }
150}
151
152/// How a provider's wire accounts reasoning/thinking tokens against the
153/// request's output budget (I1, CP-12 review). The reserve model must count
154/// thinking exactly once, and provider differences are represented
155/// explicitly instead of being summed blindly.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum ThinkingAccounting {
158    /// Thinking spends from the request's output cap: Anthropic messages
159    /// (`thinking.budget_tokens < max_tokens`), OpenAI Responses/Chat and
160    /// Codex wires (reasoning tokens count toward `max_output_tokens` /
161    /// `max_completion_tokens`). Reserve ONE envelope:
162    /// `max(output_reserve, thinking_budget)`.
163    InsideOutputBudget,
164    /// Thinking is budgeted separately from response output (Gemini
165    /// `thinkingBudget`), or the wire is unknown — reserve both envelopes,
166    /// which errs toward earlier compaction, never toward exhaustion.
167    SeparateFromOutput,
168}
169
170/// Resolve the thinking-accounting semantics for a model from its wire
171/// protocol. Unroutable models use the conservative separate-reserve model.
172pub fn thinking_accounting_for_model(model: &str) -> ThinkingAccounting {
173    use crate::runtime::openai::WireProtocol;
174    match crate::runtime::openai::resolve_route(model).map(|route| route.wire) {
175        Some(
176            WireProtocol::AnthropicMessages
177            | WireProtocol::OpenAiChatCompletions
178            | WireProtocol::OpenAiResponses
179            | WireProtocol::CodexResponses,
180        ) => ThinkingAccounting::InsideOutputBudget,
181        Some(WireProtocol::GoogleGeminiCodeAssist) | None => ThinkingAccounting::SeparateFromOutput,
182    }
183}
184
185/// Typed reserves carved out of the provider window before any context is
186/// admitted.
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub struct ContextReserves {
189    /// Thinking tokens reserved IN ADDITION to the output envelope. Zero on
190    /// wires where thinking already spends from the output cap.
191    pub thinking_tokens: u64,
192    pub tool_result_tokens: u64,
193    /// The request's output envelope. On [`ThinkingAccounting::InsideOutputBudget`]
194    /// wires this is `max(output_reserve, thinking_budget)` — the wire
195    /// requires the output cap to cover thinking, so the larger governs.
196    pub output_tokens: u64,
197    pub safety_margin_tokens: u64,
198    /// The wire semantics the thinking/output split was computed under.
199    pub thinking_accounting: ThinkingAccounting,
200}
201
202impl ContextReserves {
203    pub fn total(&self) -> u64 {
204        self.thinking_tokens
205            + self.tool_result_tokens
206            + self.output_tokens
207            + self.safety_margin_tokens
208    }
209}
210
211/// One request-aware budget assessment. Pure data — frontends read the
212/// decision, they never recompute it.
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub struct ContextAssessment {
215    pub provider_window: u64,
216    pub breakdown: ContextBreakdown,
217    pub reserves: ContextReserves,
218    pub history_messages: usize,
219}
220
221impl ContextAssessment {
222    /// Total conservative usage across every request segment.
223    pub fn used_tokens(&self) -> u64 {
224        self.breakdown.total()
225    }
226
227    /// Tokens the context may occupy after all reserves: window minus
228    /// reserves, floored at zero (a window smaller than the reserves has no
229    /// admissible context at all).
230    pub fn budget_tokens(&self) -> u64 {
231        self.provider_window.saturating_sub(self.reserves.total())
232    }
233
234    /// Remaining admissible capacity before the trigger fires.
235    pub fn remaining_tokens(&self) -> u64 {
236        self.budget_tokens().saturating_sub(self.used_tokens())
237    }
238
239    /// The single cross-frontend compaction trigger: usage reached the
240    /// budget AND there is enough history for a summary to fold.
241    pub fn should_compact(&self) -> bool {
242        self.history_messages >= MIN_COMPACTION_MESSAGES
243            && self.used_tokens() >= self.budget_tokens()
244    }
245}
246
247/// Compute the request-aware context assessment. This is THE budget
248/// calculation — `Runtime::assess_context` and every frontend trigger path
249/// route through here.
250pub fn assess(inputs: &ContextBudgetInputs<'_>) -> ContextAssessment {
251    let estimator = estimator_for_model(inputs.model);
252
253    let system_tokens = inputs
254        .system_prompt
255        .map(|s| estimator.estimate(s))
256        .unwrap_or(0);
257
258    // Schemas and history are charged on their SERIALIZED form: JSON keys,
259    // quoting, and escaping are bytes the provider tokenizes too.
260    let tool_schema_tokens: u64 = inputs
261        .tools_schema
262        .iter()
263        .map(|schema| estimate_serialized(&estimator, schema))
264        .sum();
265
266    let history_tokens: u64 = inputs
267        .messages
268        .iter()
269        .map(|msg| estimate_serialized(&estimator, msg))
270        .sum();
271    let framing_tokens = PER_MESSAGE_FRAMING_TOKENS * inputs.messages.len() as u64;
272
273    let skill_tokens: u64 = inputs
274        .skill_contents
275        .iter()
276        .map(|s| estimator.estimate(s))
277        .sum();
278    let memory_tokens: u64 = inputs
279        .memory_contents
280        .iter()
281        .map(|s| estimator.estimate(s))
282        .sum();
283
284    ContextAssessment {
285        provider_window: inputs.provider_window,
286        breakdown: ContextBreakdown {
287            system_tokens,
288            tool_schema_tokens,
289            history_tokens,
290            framing_tokens,
291            skill_tokens,
292            memory_tokens,
293        },
294        reserves: {
295            let accounting = thinking_accounting_for_model(inputs.model);
296            let (thinking_tokens, output_tokens) = match accounting {
297                ThinkingAccounting::InsideOutputBudget => (
298                    0,
299                    inputs
300                        .output_reserve_tokens
301                        .max(inputs.thinking_budget_tokens),
302                ),
303                ThinkingAccounting::SeparateFromOutput => {
304                    (inputs.thinking_budget_tokens, inputs.output_reserve_tokens)
305                }
306            };
307            ContextReserves {
308                thinking_tokens,
309                tool_result_tokens: inputs
310                    .next_tool_result_bytes
311                    .div_ceil(TOOL_RESULT_BYTES_PER_TOKEN),
312                output_tokens,
313                safety_margin_tokens: inputs.provider_window * SAFETY_MARGIN_PERCENT / 100,
314                thinking_accounting: accounting,
315            }
316        },
317        history_messages: inputs.messages.len(),
318    }
319}
320
321fn estimate_serialized(estimator: &TokenEstimator, value: &Value) -> u64 {
322    match serde_json::to_string(value) {
323        Ok(s) => estimator.estimate(&s),
324        // Serialization of an in-memory Value cannot realistically fail;
325        // if it ever does, fail toward compaction rather than exhaustion.
326        Err(_) => u64::MAX / 1024,
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use serde_json::json;
334    use std::sync::Arc;
335
336    #[test]
337    fn ascii_is_charged_at_the_dense_bpe_rate() {
338        // 25 ASCII chars → ceil(25 / 2.5) = 10 tokens.
339        assert_eq!(conservative_token_estimate(&"a".repeat(25)), 10);
340        assert_eq!(conservative_token_estimate(""), 0);
341    }
342
343    #[test]
344    fn non_ascii_is_charged_per_utf8_byte() {
345        // CJK: 3 bytes/char → 3 tokens each.
346        assert_eq!(conservative_token_estimate("你好"), 6);
347        // 4-byte emoji scalar → 4 tokens.
348        assert_eq!(conservative_token_estimate("😀"), 4);
349        // 2-byte Cyrillic → 2 tokens each.
350        assert_eq!(conservative_token_estimate("да"), 4);
351    }
352
353    #[test]
354    fn tiny_window_never_underflows_and_fails_toward_compaction() {
355        let messages: Vec<SharedMessage> = (0..MIN_COMPACTION_MESSAGES)
356            .map(|i| Arc::new(json!({"role": "user", "content": format!("m{i}")})) as SharedMessage)
357            .collect();
358        let assessment = assess(&ContextBudgetInputs {
359            model: "claude-sonnet-4-6",
360            provider_window: 10,
361            system_prompt: None,
362            tools_schema: &[],
363            messages: &messages,
364            skill_contents: &[],
365            memory_contents: &[],
366            thinking_budget_tokens: 1_000,
367            next_tool_result_bytes: 0,
368            output_reserve_tokens: 0,
369        });
370        assert_eq!(assessment.budget_tokens(), 0);
371        assert_eq!(assessment.remaining_tokens(), 0);
372        assert!(assessment.should_compact());
373    }
374
375    /// I1 (CP-12 review): Anthropic-wire requests spend thinking from
376    /// `max_tokens` — the reserve model must count thinking exactly once.
377    #[test]
378    fn anthropic_wire_counts_thinking_exactly_once_inside_the_output_reserve() {
379        let messages: Vec<SharedMessage> =
380            vec![Arc::new(json!({"role": "user", "content": "hi"})) as SharedMessage];
381        let window = 200_000;
382        let margin = window * SAFETY_MARGIN_PERCENT / 100;
383        let inputs = ContextBudgetInputs {
384            model: "claude-sonnet-4-6",
385            provider_window: window,
386            system_prompt: None,
387            tools_schema: &[],
388            messages: &messages,
389            skill_contents: &[],
390            memory_contents: &[],
391            thinking_budget_tokens: 8_000,
392            next_tool_result_bytes: 0,
393            output_reserve_tokens: 64_000,
394        };
395        let assessment = assess(&inputs);
396        assert_eq!(
397            assessment.reserves.thinking_accounting,
398            ThinkingAccounting::InsideOutputBudget
399        );
400        assert_eq!(
401            assessment.reserves.thinking_tokens, 0,
402            "thinking must not be reserved a second time on the Anthropic wire"
403        );
404        assert_eq!(assessment.reserves.output_tokens, 64_000);
405        assert_eq!(
406            assessment.budget_tokens(),
407            window - (64_000 + margin),
408            "budget must subtract the output/thinking envelope exactly once"
409        );
410
411        // A thinking budget larger than the requested output governs the
412        // envelope (the wire requires max_tokens >= budget_tokens).
413        let big_thinking = ContextBudgetInputs {
414            thinking_budget_tokens: 100_000,
415            ..inputs
416        };
417        let assessment = assess(&big_thinking);
418        assert_eq!(assessment.reserves.output_tokens, 100_000);
419        assert_eq!(assessment.reserves.thinking_tokens, 0);
420    }
421
422    /// OpenAI Responses/Chat wires bill reasoning inside the output cap too.
423    #[test]
424    fn openai_wires_account_reasoning_inside_the_output_reserve() {
425        for model in ["openai-codex/gpt-5.2-codex", "xai-auth/grok-4"] {
426            let accounting = thinking_accounting_for_model(model);
427            // xai-auth requires a cataloged model; skip honestly if the
428            // catalog drops it rather than asserting a stale fixture.
429            if model.starts_with("xai-auth") && accounting == ThinkingAccounting::SeparateFromOutput
430            {
431                continue;
432            }
433            assert_eq!(
434                accounting,
435                ThinkingAccounting::InsideOutputBudget,
436                "{model}"
437            );
438        }
439    }
440
441    /// Gemini budgets thinking separately from response output; unroutable
442    /// models fall back to the conservative separate-reserve model.
443    #[test]
444    fn gemini_and_unknown_models_reserve_thinking_separately() {
445        let messages: Vec<SharedMessage> =
446            vec![Arc::new(json!({"role": "user", "content": "hi"})) as SharedMessage];
447        let window = 200_000;
448        let margin = window * SAFETY_MARGIN_PERCENT / 100;
449        for model in ["google-gemini/gemini-2.5-pro", "totally/unroutable-model"] {
450            let assessment = assess(&ContextBudgetInputs {
451                model,
452                provider_window: window,
453                system_prompt: None,
454                tools_schema: &[],
455                messages: &messages,
456                skill_contents: &[],
457                memory_contents: &[],
458                thinking_budget_tokens: 8_000,
459                next_tool_result_bytes: 0,
460                output_reserve_tokens: 64_000,
461            });
462            assert_eq!(
463                assessment.reserves.thinking_accounting,
464                ThinkingAccounting::SeparateFromOutput,
465                "{model}"
466            );
467            assert_eq!(assessment.reserves.thinking_tokens, 8_000, "{model}");
468            assert_eq!(assessment.reserves.output_tokens, 64_000, "{model}");
469            assert_eq!(
470                assessment.budget_tokens(),
471                window - (8_000 + 64_000 + margin),
472                "{model}: separate wires reserve both envelopes"
473            );
474        }
475    }
476
477    /// `Runtime::assess_context` is the SAME calculation as `assess` on the
478    /// runtime's own segments — the Runtime surface must never fork the math.
479    #[tokio::test]
480    async fn runtime_assessment_matches_engine_calculation() {
481        let mut runtime = crate::Runtime::new_headless();
482        runtime.set_system_prompt("shared system prompt for budget parity".into());
483        runtime.set_context_window(Some(200_000));
484
485        let messages: Vec<SharedMessage> = vec![
486            Arc::new(json!({"role": "user", "content": "hello 你好 😀"})),
487            Arc::new(json!({"role": "assistant", "content": [
488                {"type": "text", "text": "fn main() { println!(\"hi\"); }"}
489            ]})),
490        ];
491
492        let via_runtime = runtime.assess_context(&messages).await;
493
494        let system = runtime.effective_system_prompt().await;
495        let schema = runtime.tools_shared().read().await.tools_schema();
496        let direct = assess(&ContextBudgetInputs {
497            model: runtime.model(),
498            provider_window: runtime.context_window(),
499            system_prompt: system.as_deref(),
500            tools_schema: &schema,
501            messages: &messages,
502            skill_contents: &[],
503            memory_contents: &[],
504            thinking_budget_tokens: runtime.thinking_budget() as u64,
505            next_tool_result_bytes: runtime.max_tool_output() as u64,
506            output_reserve_tokens: via_runtime.reserves.output_tokens,
507        });
508
509        assert_eq!(
510            via_runtime, direct,
511            "Runtime surface must not fork the budget math"
512        );
513        assert!(
514            via_runtime.reserves.output_tokens > 0,
515            "output reserve must be model-derived"
516        );
517        assert!(
518            via_runtime.breakdown.system_tokens > 0,
519            "actual system segment must be accounted"
520        );
521    }
522
523    /// Build a synthetic, in-bounds recall contribution for the runtime's
524    /// own project identity (task A7 budget-wiring tests).
525    fn synthetic_memory_contribution(
526        rendered: &str,
527    ) -> crate::runtime::memory_context::MemoryContextContribution {
528        use crate::runtime::memory_context as mc;
529        use agent_core::BoundedText;
530        mc::MemoryContextContribution {
531            schema: mc::ContributionSchemaVersion::parse("contribution/1").expect("valid schema"),
532            provider_id: mc::ContextProviderId::parse("axel-memory").expect("valid provider"),
533            project_id: crate::runtime::memory_project_id(),
534            records: vec![mc::MemoryContributionRecord {
535                memory_id: mc::MemoryId::parse("mem-0001").expect("valid memory id"),
536                source: mc::MemorySource::ChatHistory,
537                timestamp: std::time::SystemTime::UNIX_EPOCH,
538                rank_reason: vec![mc::RankReason::ExactTopic],
539                sensitivity: agent_core::core::disclosure::DisclosureClass::ModelVisible,
540                retention: mc::RetentionClass::Standard,
541                content: BoundedText::new("the project uses session-scoped authorization", 2048),
542                truncated: false,
543                supersedes: None,
544            }],
545            rendered: BoundedText::new(rendered, 16 * 1024),
546            accounting: mc::ContributionAccounting::default(),
547        }
548    }
549
550    /// Task A7 (spec §10.3): a held recall contribution is charged EXACTLY
551    /// to the `memory_tokens` breakdown lane — `used_tokens`/`remaining_tokens`
552    /// move by exactly the estimated memory tokens, every other breakdown
553    /// lane is unchanged, and the typed reserves (thinking, tool-result,
554    /// output, safety margin) are bit-for-bit identical with and without the
555    /// contribution: memory competes for budget headroom only, NEVER for
556    /// reserves. Clearing the contribution restores the assessment
557    /// bit-for-bit.
558    #[tokio::test]
559    async fn memory_contribution_charges_only_the_memory_lane_and_never_reserves() {
560        let mut runtime = crate::Runtime::new_headless();
561        runtime.set_system_prompt("system prompt for the memory budget test".into());
562        runtime.set_context_window(Some(200_000));
563
564        let messages: Vec<SharedMessage> = vec![
565            Arc::new(json!({"role": "user", "content": "what auth model do we use?"})),
566            Arc::new(json!({"role": "assistant", "content": "let me check"})),
567        ];
568
569        let without = runtime.assess_context(&messages).await;
570        assert_eq!(without.breakdown.memory_tokens, 0);
571
572        let rendered = "[Axel memory — lower-authority project data; verify before relying]\n\
573                        1. mem-0001 — Decision — the project uses session-scoped \
574                        authorization rather than persisted grants.";
575        runtime
576            .hold_memory_contribution(synthetic_memory_contribution(rendered))
577            .expect("in-bounds contribution for the runtime's own project is accepted");
578        let with = runtime.assess_context(&messages).await;
579
580        let expected_memory = conservative_token_estimate(rendered);
581        assert!(expected_memory > 0, "test contribution must be non-empty");
582        assert_eq!(
583            with.breakdown.memory_tokens, expected_memory,
584            "memory lane must carry exactly the estimated rendered tokens"
585        );
586
587        // Reserves: bit-for-bit identical. Memory never touches thinking,
588        // tool-result, output, or safety-margin reserves.
589        assert_eq!(
590            with.reserves, without.reserves,
591            "reserves must be COMPLETELY unaffected by memory content"
592        );
593        assert_eq!(
594            with.budget_tokens(),
595            without.budget_tokens(),
596            "the admissible budget (window minus reserves) must not move"
597        );
598
599        // Every non-memory breakdown lane is unchanged.
600        assert_eq!(
601            with.breakdown.system_tokens,
602            without.breakdown.system_tokens
603        );
604        assert_eq!(
605            with.breakdown.tool_schema_tokens,
606            without.breakdown.tool_schema_tokens
607        );
608        assert_eq!(
609            with.breakdown.history_tokens,
610            without.breakdown.history_tokens
611        );
612        assert_eq!(
613            with.breakdown.framing_tokens,
614            without.breakdown.framing_tokens
615        );
616        assert_eq!(with.breakdown.skill_tokens, without.breakdown.skill_tokens);
617        assert_eq!(with.history_messages, without.history_messages);
618
619        // Usage moves by exactly the memory estimate — in both directions.
620        assert_eq!(with.used_tokens(), without.used_tokens() + expected_memory);
621        assert_eq!(
622            with.remaining_tokens(),
623            without.remaining_tokens() - expected_memory
624        );
625
626        // should_compact still keys off the SAME budget, now with memory
627        // usage included (not asserted to flip here — the contribution is
628        // far smaller than the window; the invariant is the inputs).
629        assert_eq!(
630            with.should_compact(),
631            with.history_messages >= MIN_COMPACTION_MESSAGES
632                && with.used_tokens() >= with.budget_tokens()
633        );
634
635        // Clearing the held segment restores the assessment bit-for-bit —
636        // the no-contribution path is byte-identical to before task A7.
637        runtime.clear_memory_contribution();
638        let cleared = runtime.assess_context(&messages).await;
639        assert_eq!(
640            cleared, without,
641            "no held contribution ⇒ assessment identical to the empty lane"
642        );
643    }
644
645    /// Task A7 fail-closed gate (spec §5.2): a contribution whose project
646    /// identity does not match the host's is refused at hold time and never
647    /// reaches the budget lane.
648    #[tokio::test]
649    async fn foreign_project_contribution_is_refused_and_never_budgeted() {
650        use crate::runtime::memory_context as mc;
651        let mut runtime = crate::Runtime::new_headless();
652        runtime.set_context_window(Some(200_000));
653
654        let mut contribution = synthetic_memory_contribution("foreign project memory");
655        contribution.project_id =
656            mc::ProjectId::parse("project-not-this-host").expect("valid project id");
657        assert_eq!(
658            runtime.hold_memory_contribution(contribution),
659            Err(mc::MemoryContextError::ContributionProjectMismatch)
660        );
661
662        let messages: Vec<SharedMessage> =
663            vec![Arc::new(json!({"role": "user", "content": "hi"})) as SharedMessage];
664        let assessment = runtime.assess_context(&messages).await;
665        assert_eq!(
666            assessment.breakdown.memory_tokens, 0,
667            "a refused contribution must never be budgeted"
668        );
669    }
670}