Skip to main content

distri_types/
execution.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use serde_json::json;
4
5use crate::{Part, PlanStep, TaskStatus, ToolResponse, core::FileType};
6
7/// Execution strategy types
8#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
9pub enum ExecutionType {
10    Interleaved,
11    Retriable,
12    React,
13    Code,
14}
15
16/// Execution result with detailed information
17#[derive(Debug, Clone, JsonSchema, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub struct ExecutionResult {
20    pub step_id: String,
21    pub parts: Vec<Part>,
22    pub status: ExecutionStatus,
23    pub reason: Option<String>, // for rejection or failure
24    pub timestamp: i64,
25}
26
27impl ExecutionResult {
28    pub fn is_success(&self) -> bool {
29        self.status == ExecutionStatus::Success || self.status == ExecutionStatus::InputRequired
30    }
31    pub fn is_failed(&self) -> bool {
32        self.status == ExecutionStatus::Failed
33    }
34    pub fn is_rejected(&self) -> bool {
35        self.status == ExecutionStatus::Rejected
36    }
37    pub fn is_input_required(&self) -> bool {
38        self.status == ExecutionStatus::InputRequired
39    }
40
41    pub fn as_observation(&self) -> String {
42        // No per-turn truncation. The function used to chop
43        // `Part::Text` at 1_000 chars and `Part::Data` /
44        // `Part::ToolResult` at 500 — that silently strips skill
45        // bodies, large tool outputs, and anything else the LLM needs
46        // verbatim on the next turn. Bounding is the emitting tool's
47        // job (planned: `Tool::output_budget`), not the prompt
48        // builder's. See
49        // `docs/specs/2026-05-10-tool-output-bounds-and-context-compaction.md`.
50
51        // Phase 6.4: Empty result guard — prevents model issues with empty tool results
52        let has_content = self.parts.iter().any(|p| match p {
53            Part::Text(t) => !t.trim().is_empty(),
54            _ => true,
55        });
56        if !has_content && self.reason.is_none() {
57            return format!("({} completed with no output)", self.step_id);
58        }
59
60        let mut txt = String::new();
61        if let Some(reason) = &self.reason {
62            txt.push_str(reason);
63        }
64        let parts_txt = self
65            .parts
66            .iter()
67            .map(|p| match p {
68                Part::Text(text) => text.clone(),
69                Part::ToolCall(tool_call) => format!(
70                    "Action: {} with {}",
71                    tool_call.tool_name,
72                    serde_json::to_string(&tool_call.input).unwrap_or_default()
73                ),
74                Part::Data(data) => serde_json::to_string(&data).unwrap_or_default(),
75                Part::ToolResult(tool_result) => {
76                    serde_json::to_string(&tool_result.result()).unwrap_or_default()
77                }
78                Part::Image(image) => match image {
79                    FileType::Url { url, .. } => format!("[Image: {}]", url),
80                    FileType::Bytes {
81                        name, mime_type, ..
82                    } => format!(
83                        "[Image: {} ({})]",
84                        name.as_deref().unwrap_or("unnamed"),
85                        mime_type
86                    ),
87                },
88                Part::File(file) => match file {
89                    FileType::Url { url, .. } => format!("[File: {}]", url),
90                    FileType::Bytes {
91                        name, mime_type, ..
92                    } => format!(
93                        "[File: {} ({})]",
94                        name.as_deref().unwrap_or("unnamed"),
95                        mime_type
96                    ),
97                },
98                // Phase 6.2: Include artifact preview in observation
99                Part::Artifact(artifact) => {
100                    let preview = artifact
101                        .preview
102                        .as_deref()
103                        .map(|p| format!("\nPreview:\n{}", p))
104                        .unwrap_or_default();
105                    let stats_info = artifact
106                        .stats
107                        .as_ref()
108                        .map(|s| format!("{} — ", s.context_info()))
109                        .unwrap_or_default();
110                    format!(
111                        "[Artifact: {}{}\n... ({}use artifact tools for full content)]",
112                        artifact.file_id, preview, stats_info
113                    )
114                }
115                Part::ResourceLink(link) => match link.text.as_deref() {
116                    Some(text) if !text.is_empty() => text.to_string(),
117                    _ => format!("[Resource: {}]", link.uri),
118                },
119            })
120            .collect::<Vec<_>>()
121            .join("\n");
122        if !parts_txt.is_empty() {
123            txt.push('\n');
124            txt.push_str(&parts_txt);
125        }
126        txt
127    }
128
129    /// Compact execution results for the prompt history slice.
130    ///
131    /// `strip_inline_files = true` replaces `Image` / `File` parts with text
132    /// placeholders — appropriate for entries that are NOT the latest tool
133    /// result, where we don't want N copies of the same image fattening
134    /// every subsequent turn. `strip_inline_files = false` keeps the
135    /// inline bytes — appropriate at storage time and for the latest
136    /// tool result, so the very next planning turn can actually see the
137    /// image the tool just returned.
138    pub fn compact_for_history(&self) -> Self {
139        self.compact_for_history_with(true)
140    }
141
142    pub fn compact_for_history_with(&self, strip_inline_files: bool) -> Self {
143        const MAX_TEXT_CHARS: usize = 2_000;
144        const MAX_JSON_CHARS: usize = 4_000;
145
146        fn truncate(value: &str, max: usize) -> String {
147            if value.chars().count() <= max {
148                return value.to_string();
149            }
150
151            let truncated: String = value.chars().take(max).collect();
152            format!(
153                "{}\n...[truncated {} chars for history]",
154                truncated,
155                value.chars().count().saturating_sub(max)
156            )
157        }
158
159        fn compact_json(value: &serde_json::Value, max: usize) -> serde_json::Value {
160            match serde_json::to_string(value) {
161                Ok(serialized) if serialized.chars().count() > max => json!({
162                    "summary": "JSON payload omitted from history due to size",
163                    "preview": truncate(&serialized, std::cmp::min(500, max)),
164                    "truncated": true,
165                    "original_chars": serialized.chars().count()
166                }),
167                Ok(_) => value.clone(),
168                Err(_) => {
169                    json!({ "summary": "JSON payload omitted from history (serialization failed)" })
170                }
171            }
172        }
173
174        let compacted_parts = self
175            .parts
176            .iter()
177            .map(|part| match part {
178                Part::Text(text) => Part::Text(truncate(text, MAX_TEXT_CHARS)),
179                Part::Data(data) => Part::Data(compact_json(data, MAX_JSON_CHARS)),
180                Part::ToolCall(tool_call) => {
181                    let mut compacted_call = tool_call.clone();
182                    compacted_call.input = compact_json(&tool_call.input, MAX_JSON_CHARS);
183                    Part::ToolCall(compacted_call)
184                }
185                Part::ToolResult(tool_result) => {
186                    let filtered = tool_result.filter_for_save();
187                    let compacted_tool_parts = filtered
188                        .parts
189                        .iter()
190                        .map(|tool_part| match tool_part {
191                            Part::Text(text) => Part::Text(truncate(text, MAX_TEXT_CHARS)),
192                            Part::Data(data) => Part::Data(compact_json(data, MAX_JSON_CHARS)),
193                            Part::Image(image) if strip_inline_files => Part::Text(
194                                "[Image omitted from history; use artifact/reference if needed]"
195                                    .to_string(),
196                            ),
197                            Part::Image(image) => Part::Image(image.clone()),
198                            other => other.clone(),
199                        })
200                        .collect();
201
202                    Part::ToolResult(ToolResponse {
203                        tool_call_id: filtered.tool_call_id,
204                        tool_name: filtered.tool_name,
205                        parts: compacted_tool_parts,
206                        parts_metadata: None,
207                    })
208                }
209                Part::Image(_) if strip_inline_files => {
210                    Part::Text("[Image omitted from history to reduce context size]".to_string())
211                }
212                Part::Image(image) => Part::Image(image.clone()),
213                Part::File(_) if strip_inline_files => {
214                    Part::Text("[File omitted from history to reduce context size]".to_string())
215                }
216                Part::File(file) => Part::File(file.clone()),
217                Part::Artifact(artifact) => Part::Artifact(artifact.clone()),
218                Part::ResourceLink(link) => Part::ResourceLink(link.clone()),
219            })
220            .collect();
221
222        Self {
223            step_id: self.step_id.clone(),
224            parts: compacted_parts,
225            status: self.status.clone(),
226            reason: self.reason.as_ref().map(|r| truncate(r, MAX_TEXT_CHARS)),
227            timestamp: self.timestamp,
228        }
229    }
230
231    /// Maximum tokens for a single tool result in the scratchpad.
232    pub const MAX_TOOL_RESULT_TOKENS: usize = 500;
233
234    /// Ensure the result has at least one part. If empty, injects a "[No output]" guard.
235    pub fn with_empty_guard(mut self) -> Self {
236        if self.parts.is_empty() {
237            self.parts.push(Part::Text("[No output]".to_string()));
238        }
239        self
240    }
241
242    /// Compact for storage. Truncates oversized text/JSON but **keeps inline
243    /// images and files**, so the very next planning turn can still see the
244    /// image a tool just returned. Display-time compaction
245    /// (`compact_for_history`, called by the formatter for non-latest
246    /// scratchpad entries) is what strips images from the rolling context.
247    pub fn compact_for_storage(&self) -> Self {
248        self.compact_for_history_with(false).with_empty_guard()
249    }
250}
251
252#[derive(Debug, Clone, JsonSchema, Serialize, Deserialize, PartialEq, Eq)]
253#[serde(rename_all = "snake_case")]
254pub enum ExecutionStatus {
255    Success,
256    Failed,
257    Rejected,
258    InputRequired,
259}
260
261impl From<ExecutionStatus> for TaskStatus {
262    fn from(val: ExecutionStatus) -> Self {
263        match val {
264            ExecutionStatus::Success => TaskStatus::Completed,
265            ExecutionStatus::Failed => TaskStatus::Failed,
266            ExecutionStatus::Rejected => TaskStatus::Canceled,
267            ExecutionStatus::InputRequired => TaskStatus::InputRequired,
268        }
269    }
270}
271
272pub enum ToolResultWithSkip {
273    ToolResult(ToolResponse),
274    // Skip tool call if it is external
275    Skip {
276        tool_call_id: String,
277        reason: String,
278    },
279}
280
281pub fn from_tool_results(tool_results: Vec<ToolResultWithSkip>) -> Vec<Part> {
282    tool_results
283        .iter()
284        .filter_map(|result| match result {
285            ToolResultWithSkip::ToolResult(tool_result) => {
286                // Simply extract parts from the tool response
287                Some(tool_result.parts.clone())
288            }
289            _ => None,
290        })
291        .flatten()
292        .collect()
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize, Default)]
296pub struct ContextUsage {
297    pub tokens: u32,
298    pub input_tokens: u32,
299    pub output_tokens: u32,
300    /// Tokens read from provider cache (e.g., Anthropic prompt caching)
301    #[serde(default)]
302    pub cached_tokens: u32,
303    pub current_iteration: usize,
304    pub context_size: ContextSize,
305    /// Model used for LLM calls in this context
306    #[serde(default)]
307    pub model: Option<String>,
308    /// Per-component token budget tracking for context optimization
309    #[serde(default)]
310    pub context_budget: ContextBudget,
311    /// Snapshot taken at the start of each step — used to compute per-step deltas
312    #[serde(default)]
313    pub step_input_start: u32,
314    #[serde(default)]
315    pub step_output_start: u32,
316    #[serde(default)]
317    pub step_cached_start: u32,
318}
319
320/// Tracks token usage by component for context optimization.
321///
322/// Each field represents the estimated token count for a specific component
323/// of the prompt. This enables:
324/// - Monitoring which components consume the most context
325/// - Triggering compaction when utilization exceeds thresholds
326/// - Informing deferred loading decisions (tools, skills)
327/// - API-side prompt caching optimization
328#[derive(Debug, Clone, Serialize, Deserialize, Default)]
329pub struct ContextBudget {
330    /// Static system prompt tokens (cacheable across sessions)
331    pub system_prompt_static_tokens: usize,
332    /// Dynamic system prompt tokens (per-session: env, memory, hooks)
333    pub system_prompt_dynamic_tokens: usize,
334    /// Tool schema tokens (full schemas for core tools)
335    pub tool_schema_tokens: usize,
336    /// Deferred tool listing tokens (name + description only)
337    pub deferred_tool_tokens: usize,
338    /// Skill listing tokens in system prompt
339    pub skill_listing_tokens: usize,
340    /// Conversation history tokens (all messages)
341    pub conversation_tokens: usize,
342    /// Tool result tokens in current turn
343    pub tool_result_tokens: usize,
344    /// Total estimated context window size for the model
345    pub context_window_size: usize,
346    /// Whether the static prompt prefix hash has changed (cache bust)
347    pub static_prefix_cache_hit: bool,
348    /// Hash of the static system prompt prefix for cache tracking
349    #[serde(default)]
350    pub static_prefix_hash: Option<String>,
351}
352
353impl ContextBudget {
354    /// Total tokens currently consumed across all components
355    pub fn total_tokens(&self) -> usize {
356        self.system_prompt_static_tokens
357            + self.system_prompt_dynamic_tokens
358            + self.tool_schema_tokens
359            + self.deferred_tool_tokens
360            + self.skill_listing_tokens
361            + self.conversation_tokens
362            + self.tool_result_tokens
363    }
364
365    /// Context utilization as a percentage (0.0 - 1.0)
366    pub fn utilization(&self) -> f64 {
367        if self.context_window_size == 0 {
368            return 0.0;
369        }
370        self.total_tokens() as f64 / self.context_window_size as f64
371    }
372
373    /// Remaining tokens available in the context window
374    pub fn remaining_tokens(&self) -> usize {
375        self.context_window_size.saturating_sub(self.total_tokens())
376    }
377
378    /// Whether context utilization exceeds the warning threshold (80%)
379    pub fn is_warning(&self) -> bool {
380        self.utilization() > 0.80
381    }
382
383    /// Whether context utilization exceeds the critical threshold (90%)
384    pub fn is_critical(&self) -> bool {
385        self.utilization() > 0.90
386    }
387
388    /// Tokens saved by deferring tools (vs loading all schemas)
389    pub fn deferred_savings(&self) -> usize {
390        // This would be set externally by comparing full vs deferred tool tokens
391        0 // Placeholder - actual savings tracked by tool resolution
392    }
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize, Default)]
396pub struct ContextSize {
397    pub message_count: usize,
398    pub message_chars: usize,
399    pub message_estimated_tokens: usize,
400    pub execution_history_count: usize,
401    pub execution_history_chars: usize,
402    pub execution_history_estimated_tokens: usize,
403    pub scratchpad_chars: usize,
404    pub scratchpad_estimated_tokens: usize,
405    pub total_chars: usize,
406    pub total_estimated_tokens: usize,
407    /// Per-agent context size breakdown
408    pub agent_breakdown: std::collections::HashMap<String, AgentContextSize>,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize, Default)]
412pub struct AgentContextSize {
413    pub agent_id: String,
414    pub task_count: usize,
415    pub execution_history_count: usize,
416    pub execution_history_chars: usize,
417    pub execution_history_estimated_tokens: usize,
418    pub scratchpad_chars: usize,
419    pub scratchpad_estimated_tokens: usize,
420}
421
422/// Enriched execution history entry that includes context metadata
423#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct ExecutionHistoryEntry {
425    pub thread_id: String, // Conversation context
426    pub task_id: String,   // Individual user task/request
427    pub run_id: String,    // Specific execution strand
428    pub execution_result: ExecutionResult,
429    pub stored_at: i64, // When this was stored
430}
431
432/// Entry for scratchpad formatting
433#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct ScratchpadEntry {
435    pub timestamp: i64,
436    #[serde(flatten)]
437    pub entry_type: ScratchpadEntryType,
438    pub task_id: String,
439    #[serde(default)]
440    pub parent_task_id: Option<String>,
441    pub entry_kind: Option<String>,
442}
443
444/// Type of scratchpad entry - only for Thought/Action/Observation tracking
445#[derive(Debug, Clone, Serialize, Deserialize)]
446#[serde(rename_all = "snake_case", tag = "type", content = "data")]
447pub enum ScratchpadEntryType {
448    #[serde(rename = "task")]
449    Task(Vec<Part>),
450    #[serde(rename = "plan")]
451    PlanStep(PlanStep),
452    #[serde(rename = "execution")]
453    Execution(ExecutionHistoryEntry),
454    /// Compressed summary produced by Tier 2 (semantic) compaction
455    #[serde(rename = "summary")]
456    Summary(CompactionSummary),
457    /// Skill content re-injected after compaction
458    #[serde(rename = "skill_context")]
459    SkillContext(SkillContextEntry),
460}
461
462/// Skill content re-injected after compaction to preserve agent instructions
463#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct SkillContextEntry {
465    /// Skill identifier
466    pub skill_id: String,
467    /// Full skill content (markdown)
468    pub content: String,
469    /// Timestamp when this was re-injected
470    pub reinjected_at: i64,
471}
472
473/// Summary produced by semantic compaction of older scratchpad entries
474#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct CompactionSummary {
476    /// LLM-generated summary of compacted history
477    pub summary_text: String,
478    /// Number of entries that were summarized
479    pub entries_summarized: usize,
480    /// Timestamp range of summarized entries
481    pub from_timestamp: i64,
482    pub to_timestamp: i64,
483    /// Token count saved by this compaction
484    pub tokens_saved: usize,
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use serde_json::json;
491
492    #[test]
493    fn test_scratchpad_large_observation_issue() {
494        println!("=== TESTING LARGE DATA OBSERVATION IN SCRATCHPAD ===");
495
496        // Create a very large tool response observation (similar to search results)
497        let large_data = json!({
498            "results": (0..100).map(|i| json!({
499                "id": i,
500                "name": format!("Minister {}", i),
501                "email": format!("minister{}@gov.sg", i),
502                "portfolio": format!("Ministry of Complex Affairs {}", i),
503                "biography": format!("Very long biography text that goes on and on for minister {} with lots of details about their career, education, achievements, and political history. This is intentionally verbose to demonstrate the issue with large content in scratchpad observations.", i),
504            })).collect::<Vec<_>>()
505        });
506
507        println!(
508            "Large data size: {} bytes",
509            serde_json::to_string(&large_data).unwrap().len()
510        );
511
512        // Test 1: Direct Part::Data (BROKEN - causes scratchpad bloat)
513        let execution_result_data = ExecutionResult {
514            step_id: "test-step-1".to_string(),
515            parts: vec![Part::Data(large_data.clone())],
516            status: ExecutionStatus::Success,
517            reason: None,
518            timestamp: 1234567890,
519        };
520
521        let observation_data = execution_result_data.as_observation();
522        println!(
523            "🚨 BROKEN: Direct Part::Data observation size: {} chars",
524            observation_data.len()
525        );
526        println!(
527            "Preview (first 200 chars): {}",
528            &observation_data.chars().take(200).collect::<String>()
529        );
530
531        // Test 2: File metadata (GOOD - concise)
532        let file_metadata = crate::filesystem::FileMetadata {
533            file_id: "large-search-results.json".to_string(),
534            relative_path: "thread123/task456/large-search-results.json".to_string(),
535            size: serde_json::to_string(&large_data).unwrap().len() as u64,
536            content_type: Some("application/json".to_string()),
537            original_filename: Some("search_results.json".to_string()),
538            created_at: chrono::Utc::now(),
539            updated_at: chrono::Utc::now(),
540            checksum: Some("abc123".to_string()),
541            stats: None,
542            preview: Some("JSON search results with 100 minister entries".to_string()),
543        };
544
545        let execution_result_file = ExecutionResult {
546            step_id: "test-step-2".to_string(),
547            parts: vec![Part::Artifact(file_metadata)],
548            status: ExecutionStatus::Success,
549            reason: None,
550            timestamp: 1234567890,
551        };
552
553        let observation_file = execution_result_file.as_observation();
554        println!(
555            "✅ GOOD: File metadata observation size: {} chars",
556            observation_file.len()
557        );
558        println!("Content: {}", observation_file);
559
560        // Demonstrate the problem
561        println!("\n=== SCRATCHPAD IMPACT ===");
562        println!(
563            "❌ Direct approach adds {} chars to scratchpad (CAUSES LOOPS!)",
564            observation_data.len()
565        );
566        println!(
567            "✅ File metadata adds only {} chars to scratchpad",
568            observation_file.len()
569        );
570        println!(
571            "💡 Size reduction: {:.1}%",
572            (1.0 - (observation_file.len() as f64 / observation_data.len() as f64)) * 100.0
573        );
574
575        // This test shows the fix is working - observations are now truncated
576        assert!(observation_data.len() < 1000, "Large data is now truncated"); // Fixed expectation
577        assert!(
578            observation_file.len() < 300,
579            "File metadata stays reasonably concise"
580        ); // Updated for detailed format
581
582        println!("\n🚨 CONCLUSION: as_observation() needs to truncate large Part::Data!");
583    }
584
585    #[test]
586    fn test_observation_truncation_fix() {
587        println!("=== TESTING OBSERVATION TRUNCATION FIX ===");
588
589        // Test large data truncation
590        let large_data = json!({
591            "big_array": (0..200).map(|i| format!("item_{}", i)).collect::<Vec<_>>()
592        });
593
594        let execution_result = ExecutionResult {
595            step_id: "test-truncation".to_string(),
596            parts: vec![Part::Data(large_data)],
597            status: ExecutionStatus::Success,
598            reason: None,
599            timestamp: 1234567890,
600        };
601
602        let observation = execution_result.as_observation();
603        println!("Truncated observation size: {} chars", observation.len());
604        println!("Content: {}", observation);
605
606        // Should be truncated and include total char count
607        assert!(
608            observation.len() < 600,
609            "Observation should be truncated to <600 chars"
610        );
611        assert!(
612            observation.contains("truncated"),
613            "Should indicate truncation"
614        );
615        assert!(
616            observation.contains("total chars"),
617            "Should show total char count"
618        );
619
620        // Test long text truncation
621        let long_text = "This is a very long text. ".repeat(100);
622        let text_result = ExecutionResult {
623            step_id: "test-text-truncation".to_string(),
624            parts: vec![Part::Text(long_text.clone())],
625            status: ExecutionStatus::Success,
626            reason: None,
627            timestamp: 1234567890,
628        };
629
630        let text_observation = text_result.as_observation();
631        println!("Text observation size: {} chars", text_observation.len());
632        assert!(
633            text_observation.len() < 1100,
634            "Text should be truncated to ~1000 chars"
635        );
636        if long_text.len() > 1000 {
637            assert!(
638                text_observation.contains("truncated"),
639                "Long text should be truncated"
640            );
641        }
642
643        println!("✅ Observation truncation is working!");
644    }
645
646    #[test]
647    fn test_compact_for_history_filters_save_false_and_truncates_large_parts() {
648        let mut parts_metadata = std::collections::HashMap::new();
649        parts_metadata.insert(
650            1,
651            crate::PartMetadata {
652                save: false,
653                ..Default::default()
654            },
655        );
656
657        let tool_response = ToolResponse {
658            tool_call_id: "call-1".to_string(),
659            tool_name: "search".to_string(),
660            parts: vec![
661                Part::Data(json!({"small": "kept"})),
662                Part::Data(json!({"secret": "do not persist"})),
663            ],
664            parts_metadata: Some(parts_metadata),
665        };
666
667        let huge = "x".repeat(6_000);
668        let execution_result = ExecutionResult {
669            step_id: "step-1".to_string(),
670            parts: vec![
671                Part::Text("y".repeat(2_500)),
672                Part::Data(json!({"huge": huge})),
673                Part::ToolResult(tool_response),
674            ],
675            status: ExecutionStatus::Success,
676            reason: Some("z".repeat(2_500)),
677            timestamp: 0,
678        };
679
680        let compacted = execution_result.compact_for_history();
681
682        assert_eq!(compacted.parts.len(), 3);
683        let text = match &compacted.parts[0] {
684            Part::Text(value) => value,
685            other => panic!("unexpected part: {:?}", other),
686        };
687        assert!(text.contains("[truncated"));
688
689        let data = match &compacted.parts[1] {
690            Part::Data(value) => value,
691            other => panic!("unexpected part: {:?}", other),
692        };
693        assert_eq!(data["truncated"], json!(true));
694
695        let tool = match &compacted.parts[2] {
696            Part::ToolResult(value) => value,
697            other => panic!("unexpected part: {:?}", other),
698        };
699        // save:false part should be removed.
700        assert_eq!(tool.parts.len(), 1);
701        assert!(tool.parts_metadata.is_none());
702    }
703
704    #[test]
705    fn test_context_budget_total_tokens() {
706        let budget = ContextBudget {
707            system_prompt_static_tokens: 3000,
708            system_prompt_dynamic_tokens: 2000,
709            tool_schema_tokens: 5000,
710            deferred_tool_tokens: 200,
711            skill_listing_tokens: 500,
712            conversation_tokens: 10000,
713            tool_result_tokens: 1000,
714            context_window_size: 200_000,
715            static_prefix_cache_hit: false,
716            static_prefix_hash: None,
717        };
718
719        assert_eq!(budget.total_tokens(), 21700);
720        assert!((budget.utilization() - 0.1085).abs() < 0.001);
721        assert_eq!(budget.remaining_tokens(), 178300);
722        assert!(!budget.is_warning());
723        assert!(!budget.is_critical());
724    }
725
726    #[test]
727    fn test_context_budget_warning_threshold() {
728        let budget = ContextBudget {
729            conversation_tokens: 85000,
730            context_window_size: 100_000,
731            ..Default::default()
732        };
733        assert!(budget.is_warning());
734        assert!(!budget.is_critical());
735    }
736
737    #[test]
738    fn test_context_budget_critical_threshold() {
739        let budget = ContextBudget {
740            conversation_tokens: 95000,
741            context_window_size: 100_000,
742            ..Default::default()
743        };
744        assert!(budget.is_warning());
745        assert!(budget.is_critical());
746    }
747
748    #[test]
749    fn test_context_budget_zero_window() {
750        let budget = ContextBudget::default();
751        assert_eq!(budget.utilization(), 0.0);
752        assert_eq!(budget.remaining_tokens(), 0);
753    }
754}