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