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
52pub const IMAGE_TOKEN_ESTIMATE: u64 = 1_600;
59
60const PER_MESSAGE_FRAMING_TOKENS: u64 = 4;
63
64const TOOL_RESULT_BYTES_PER_TOKEN: u64 = 3;
68
69pub fn conservative_token_estimate(text: &str) -> u64 {
72 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum TokenEstimator {
90 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
103pub fn estimator_for_model(_model: &str) -> TokenEstimator {
108 TokenEstimator::Conservative
109}
110
111#[derive(Debug, Clone, Copy)]
114pub struct ContextBudgetInputs<'a> {
115 pub model: &'a str,
117 pub provider_window: u64,
119 pub system_prompt: Option<&'a str>,
121 pub tools_schema: &'a [Value],
123 pub messages: &'a [SharedMessage],
125 pub skill_contents: &'a [&'a str],
127 pub memory_contents: &'a [&'a str],
129 pub thinking_budget_tokens: u64,
131 pub next_tool_result_bytes: u64,
134 pub output_reserve_tokens: u64,
136}
137
138#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum ThinkingAccounting {
166 InsideOutputBudget,
172 SeparateFromOutput,
176}
177
178pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub struct ContextReserves {
197 pub thinking_tokens: u64,
200 pub tool_result_tokens: u64,
201 pub output_tokens: u64,
205 pub safety_margin_tokens: u64,
206 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#[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 pub fn used_tokens(&self) -> u64 {
232 self.breakdown.total()
233 }
234
235 pub fn budget_tokens(&self) -> u64 {
239 self.provider_window.saturating_sub(self.reserves.total())
240 }
241
242 pub fn remaining_tokens(&self) -> u64 {
244 self.budget_tokens().saturating_sub(self.used_tokens())
245 }
246
247 pub fn should_compact(&self) -> bool {
250 self.history_messages >= MIN_COMPACTION_MESSAGES
251 && self.used_tokens() >= self.budget_tokens()
252 }
253}
254
255pub 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 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 Err(_) => u64::MAX / 1024,
335 }
336}
337
338pub(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 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; 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 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 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 assert_eq!(conservative_token_estimate("你好"), 6);
510 assert_eq!(conservative_token_estimate("😀"), 4);
512 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 #[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 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 #[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 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 #[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 #[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 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 #[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 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 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 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 assert_eq!(
793 with.should_compact(),
794 with.history_messages >= MIN_COMPACTION_MESSAGES
795 && with.used_tokens() >= with.budget_tokens()
796 );
797
798 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 #[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}