1use serde_json::Value;
40
41use crate::SharedMessage;
42
43pub const SAFETY_MARGIN_PERCENT: u64 = 15;
46
47pub const MIN_COMPACTION_MESSAGES: usize = 4;
51
52const PER_MESSAGE_FRAMING_TOKENS: u64 = 4;
55
56const TOOL_RESULT_BYTES_PER_TOKEN: u64 = 3;
60
61pub fn conservative_token_estimate(text: &str) -> u64 {
64 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum TokenEstimator {
82 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
95pub fn estimator_for_model(_model: &str) -> TokenEstimator {
100 TokenEstimator::Conservative
101}
102
103#[derive(Debug, Clone, Copy)]
106pub struct ContextBudgetInputs<'a> {
107 pub model: &'a str,
109 pub provider_window: u64,
111 pub system_prompt: Option<&'a str>,
113 pub tools_schema: &'a [Value],
115 pub messages: &'a [SharedMessage],
117 pub skill_contents: &'a [&'a str],
119 pub memory_contents: &'a [&'a str],
121 pub thinking_budget_tokens: u64,
123 pub next_tool_result_bytes: u64,
126 pub output_reserve_tokens: u64,
128}
129
130#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum ThinkingAccounting {
158 InsideOutputBudget,
164 SeparateFromOutput,
168}
169
170pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub struct ContextReserves {
189 pub thinking_tokens: u64,
192 pub tool_result_tokens: u64,
193 pub output_tokens: u64,
197 pub safety_margin_tokens: u64,
198 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#[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 pub fn used_tokens(&self) -> u64 {
224 self.breakdown.total()
225 }
226
227 pub fn budget_tokens(&self) -> u64 {
231 self.provider_window.saturating_sub(self.reserves.total())
232 }
233
234 pub fn remaining_tokens(&self) -> u64 {
236 self.budget_tokens().saturating_sub(self.used_tokens())
237 }
238
239 pub fn should_compact(&self) -> bool {
242 self.history_messages >= MIN_COMPACTION_MESSAGES
243 && self.used_tokens() >= self.budget_tokens()
244 }
245}
246
247pub 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 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 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 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 assert_eq!(conservative_token_estimate("你好"), 6);
347 assert_eq!(conservative_token_estimate("😀"), 4);
349 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 #[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 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 #[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 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 #[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 #[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 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 #[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 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 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 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 assert_eq!(
630 with.should_compact(),
631 with.history_messages >= MIN_COMPACTION_MESSAGES
632 && with.used_tokens() >= with.budget_tokens()
633 );
634
635 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 #[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}