1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use serde_json::json;
4
5use crate::{Part, PlanStep, TaskStatus, ToolResponse, core::FileType};
6
7#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
9pub enum ExecutionType {
10 Interleaved,
11 Retriable,
12 React,
13 Code,
14}
15
16#[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>, 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 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 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 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 pub const MAX_TOOL_RESULT_TOKENS: usize = 500;
233
234 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 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 {
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 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 #[serde(default)]
302 pub cached_tokens: u32,
303 pub current_iteration: usize,
304 pub context_size: ContextSize,
305 #[serde(default)]
307 pub model: Option<String>,
308 #[serde(default)]
310 pub context_budget: ContextBudget,
311 #[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
329pub struct ContextBudget {
330 pub system_prompt_static_tokens: usize,
332 pub system_prompt_dynamic_tokens: usize,
334 pub tool_schema_tokens: usize,
336 pub deferred_tool_tokens: usize,
338 pub skill_listing_tokens: usize,
340 pub conversation_tokens: usize,
342 pub tool_result_tokens: usize,
344 pub context_window_size: usize,
346 pub static_prefix_cache_hit: bool,
348 #[serde(default)]
350 pub static_prefix_hash: Option<String>,
351}
352
353impl ContextBudget {
354 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 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 pub fn remaining_tokens(&self) -> usize {
375 self.context_window_size.saturating_sub(self.total_tokens())
376 }
377
378 pub fn is_warning(&self) -> bool {
380 self.utilization() > 0.80
381 }
382
383 pub fn is_critical(&self) -> bool {
385 self.utilization() > 0.90
386 }
387
388 pub fn deferred_savings(&self) -> usize {
390 0 }
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 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#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct ExecutionHistoryEntry {
425 pub thread_id: String, pub task_id: String, pub run_id: String, pub execution_result: ExecutionResult,
429 pub stored_at: i64, }
431
432#[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#[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 #[serde(rename = "summary")]
456 Summary(CompactionSummary),
457 #[serde(rename = "skill_context")]
459 SkillContext(SkillContextEntry),
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct SkillContextEntry {
465 pub skill_id: String,
467 pub content: String,
469 pub reinjected_at: i64,
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct CompactionSummary {
476 pub summary_text: String,
478 pub entries_summarized: usize,
480 pub from_timestamp: i64,
482 pub to_timestamp: i64,
483 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 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 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 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 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 assert!(observation_data.len() < 1000, "Large data is now truncated"); assert!(
578 observation_file.len() < 300,
579 "File metadata stays reasonably concise"
580 ); 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 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 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 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 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}