Skip to main content

distri_types/
execution.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use serde_json::json;
4
5use crate::{core::FileType, Part, PlanStep, TaskStatus, ToolResponse};
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        const MAX_DATA_CHARS: usize = 500;
43        const MAX_TEXT_CHARS: usize = 1000;
44
45        let mut txt = String::new();
46        if let Some(reason) = &self.reason {
47            txt.push_str(&reason);
48        }
49        let parts_txt = self
50            .parts
51            .iter()
52            .filter_map(|p| match p {
53                Part::Text(text) => {
54                    if text.len() > MAX_TEXT_CHARS {
55                        let truncated: String = text.chars().take(MAX_TEXT_CHARS).collect();
56                        Some(format!(
57                            "{}... [truncated, {} total chars]",
58                            truncated,
59                            text.len()
60                        ))
61                    } else {
62                        Some(text.clone())
63                    }
64                }
65                Part::ToolCall(tool_call) => Some(format!(
66                    "Action: {} with {}",
67                    tool_call.tool_name,
68                    serde_json::to_string(&tool_call.input).unwrap_or_default()
69                )),
70                Part::Data(data) => {
71                    let serialized = serde_json::to_string(&data).unwrap_or_default();
72                    if serialized.len() > MAX_DATA_CHARS {
73                        let truncated: String = serialized.chars().take(MAX_DATA_CHARS).collect();
74                        Some(format!(
75                            "{}... [truncated, {} total chars]",
76                            truncated,
77                            serialized.len()
78                        ))
79                    } else {
80                        Some(serialized)
81                    }
82                }
83                Part::ToolResult(tool_result) => {
84                    let serialized =
85                        serde_json::to_string(&tool_result.result()).unwrap_or_default();
86                    if serialized.len() > MAX_DATA_CHARS {
87                        let truncated: String = serialized.chars().take(MAX_DATA_CHARS).collect();
88                        Some(format!(
89                            "{}... [truncated, {} total chars]",
90                            truncated,
91                            serialized.len()
92                        ))
93                    } else {
94                        Some(serialized)
95                    }
96                }
97                Part::Image(image) => match image {
98                    FileType::Url { url, .. } => Some(format!("[Image: {}]", url)),
99                    FileType::Bytes {
100                        name, mime_type, ..
101                    } => Some(format!(
102                        "[Image: {} ({})]",
103                        name.as_deref().unwrap_or("unnamed"),
104                        mime_type
105                    )),
106                },
107                Part::Artifact(artifact) => Some(format!(
108                    "[Artifact ID:{}\n You can use artifact tools to read the full content\n{}]",
109                    artifact.file_id,
110                    if let Some(stats) = &artifact.stats {
111                        format!(" ({})", stats.context_info())
112                    } else {
113                        String::new()
114                    }
115                )),
116            })
117            .collect::<Vec<_>>()
118            .join("\n");
119        if !parts_txt.is_empty() {
120            txt.push_str("\n");
121            txt.push_str(&parts_txt);
122        }
123        txt
124    }
125
126    /// Compact execution results before storing in scratchpad/history used for prompt construction.
127    ///
128    /// This keeps high-signal fields (tool ids/status/artifact refs) while stripping or truncating
129    /// large payloads that would otherwise bloat subsequent model calls.
130    pub fn compact_for_history(&self) -> Self {
131        const MAX_TEXT_CHARS: usize = 2_000;
132        const MAX_JSON_CHARS: usize = 4_000;
133
134        fn truncate(value: &str, max: usize) -> String {
135            if value.chars().count() <= max {
136                return value.to_string();
137            }
138
139            let truncated: String = value.chars().take(max).collect();
140            format!(
141                "{}\n...[truncated {} chars for history]",
142                truncated,
143                value.chars().count().saturating_sub(max)
144            )
145        }
146
147        fn compact_json(value: &serde_json::Value, max: usize) -> serde_json::Value {
148            match serde_json::to_string(value) {
149                Ok(serialized) if serialized.chars().count() > max => json!({
150                    "summary": "JSON payload omitted from history due to size",
151                    "preview": truncate(&serialized, std::cmp::min(500, max)),
152                    "truncated": true,
153                    "original_chars": serialized.chars().count()
154                }),
155                Ok(_) => value.clone(),
156                Err(_) => {
157                    json!({ "summary": "JSON payload omitted from history (serialization failed)" })
158                }
159            }
160        }
161
162        let compacted_parts = self
163            .parts
164            .iter()
165            .map(|part| match part {
166                Part::Text(text) => Part::Text(truncate(text, MAX_TEXT_CHARS)),
167                Part::Data(data) => Part::Data(compact_json(data, MAX_JSON_CHARS)),
168                Part::ToolCall(tool_call) => {
169                    let mut compacted_call = tool_call.clone();
170                    compacted_call.input = compact_json(&tool_call.input, MAX_JSON_CHARS);
171                    Part::ToolCall(compacted_call)
172                }
173                Part::ToolResult(tool_result) => {
174                    let filtered = tool_result.filter_for_save();
175                    let compacted_tool_parts = filtered
176                        .parts
177                        .iter()
178                        .map(|tool_part| match tool_part {
179                            Part::Text(text) => Part::Text(truncate(text, MAX_TEXT_CHARS)),
180                            Part::Data(data) => Part::Data(compact_json(data, MAX_JSON_CHARS)),
181                            // Keep artifact references; drop inline images from rolling context.
182                            Part::Image(_) => Part::Text(
183                                "[Image omitted from history; use artifact/reference if needed]"
184                                    .to_string(),
185                            ),
186                            other => other.clone(),
187                        })
188                        .collect();
189
190                    Part::ToolResult(ToolResponse {
191                        tool_call_id: filtered.tool_call_id,
192                        tool_name: filtered.tool_name,
193                        parts: compacted_tool_parts,
194                        parts_metadata: None,
195                    })
196                }
197                Part::Image(_) => {
198                    Part::Text("[Image omitted from history to reduce context size]".to_string())
199                }
200                Part::Artifact(artifact) => Part::Artifact(artifact.clone()),
201            })
202            .collect();
203
204        Self {
205            step_id: self.step_id.clone(),
206            parts: compacted_parts,
207            status: self.status.clone(),
208            reason: self.reason.as_ref().map(|r| truncate(r, MAX_TEXT_CHARS)),
209            timestamp: self.timestamp,
210        }
211    }
212}
213
214#[derive(Debug, Clone, JsonSchema, Serialize, Deserialize, PartialEq, Eq)]
215#[serde(rename_all = "snake_case")]
216pub enum ExecutionStatus {
217    Success,
218    Failed,
219    Rejected,
220    InputRequired,
221}
222
223impl Into<TaskStatus> for ExecutionStatus {
224    fn into(self) -> TaskStatus {
225        match self {
226            ExecutionStatus::Success => TaskStatus::Completed,
227            ExecutionStatus::Failed => TaskStatus::Failed,
228            ExecutionStatus::Rejected => TaskStatus::Canceled,
229            ExecutionStatus::InputRequired => TaskStatus::InputRequired,
230        }
231    }
232}
233
234pub enum ToolResultWithSkip {
235    ToolResult(ToolResponse),
236    // Skip tool call if it is external
237    Skip {
238        tool_call_id: String,
239        reason: String,
240    },
241}
242
243pub fn from_tool_results(tool_results: Vec<ToolResultWithSkip>) -> Vec<Part> {
244    tool_results
245        .iter()
246        .filter_map(|result| match result {
247            ToolResultWithSkip::ToolResult(tool_result) => {
248                // Simply extract parts from the tool response
249                Some(tool_result.parts.clone())
250            }
251            _ => None,
252        })
253        .flatten()
254        .collect()
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize, Default)]
258pub struct ContextUsage {
259    pub tokens: u32,
260    pub input_tokens: u32,
261    pub output_tokens: u32,
262    pub current_iteration: usize,
263    pub context_size: ContextSize,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize, Default)]
267pub struct ContextSize {
268    pub message_count: usize,
269    pub message_chars: usize,
270    pub message_estimated_tokens: usize,
271    pub execution_history_count: usize,
272    pub execution_history_chars: usize,
273    pub execution_history_estimated_tokens: usize,
274    pub scratchpad_chars: usize,
275    pub scratchpad_estimated_tokens: usize,
276    pub total_chars: usize,
277    pub total_estimated_tokens: usize,
278    /// Per-agent context size breakdown
279    pub agent_breakdown: std::collections::HashMap<String, AgentContextSize>,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, Default)]
283pub struct AgentContextSize {
284    pub agent_id: String,
285    pub task_count: usize,
286    pub execution_history_count: usize,
287    pub execution_history_chars: usize,
288    pub execution_history_estimated_tokens: usize,
289    pub scratchpad_chars: usize,
290    pub scratchpad_estimated_tokens: usize,
291}
292
293/// Enriched execution history entry that includes context metadata
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct ExecutionHistoryEntry {
296    pub thread_id: String, // Conversation context
297    pub task_id: String,   // Individual user task/request
298    pub run_id: String,    // Specific execution strand
299    pub execution_result: ExecutionResult,
300    pub stored_at: i64, // When this was stored
301}
302
303/// Entry for scratchpad formatting
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct ScratchpadEntry {
306    pub timestamp: i64,
307    #[serde(flatten)]
308    pub entry_type: ScratchpadEntryType,
309    pub task_id: String,
310    #[serde(default)]
311    pub parent_task_id: Option<String>,
312    pub entry_kind: Option<String>,
313}
314
315/// Type of scratchpad entry - only for Thought/Action/Observation tracking
316#[derive(Debug, Clone, Serialize, Deserialize)]
317#[serde(rename_all = "snake_case", tag = "type", content = "data")]
318pub enum ScratchpadEntryType {
319    #[serde(rename = "task")]
320    Task(Vec<Part>),
321    #[serde(rename = "plan")]
322    PlanStep(PlanStep),
323    #[serde(rename = "execution")]
324    Execution(ExecutionHistoryEntry),
325    /// Compressed summary produced by Tier 2 (semantic) compaction
326    #[serde(rename = "summary")]
327    Summary(CompactionSummary),
328}
329
330/// Summary produced by semantic compaction of older scratchpad entries
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct CompactionSummary {
333    /// LLM-generated summary of compacted history
334    pub summary_text: String,
335    /// Number of entries that were summarized
336    pub entries_summarized: usize,
337    /// Timestamp range of summarized entries
338    pub from_timestamp: i64,
339    pub to_timestamp: i64,
340    /// Token count saved by this compaction
341    pub tokens_saved: usize,
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use serde_json::json;
348
349    #[test]
350    fn test_scratchpad_large_observation_issue() {
351        println!("=== TESTING LARGE DATA OBSERVATION IN SCRATCHPAD ===");
352
353        // Create a very large tool response observation (similar to search results)
354        let large_data = json!({
355            "results": (0..100).map(|i| json!({
356                "id": i,
357                "name": format!("Minister {}", i),
358                "email": format!("minister{}@gov.sg", i),
359                "portfolio": format!("Ministry of Complex Affairs {}", i),
360                "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),
361            })).collect::<Vec<_>>()
362        });
363
364        println!(
365            "Large data size: {} bytes",
366            serde_json::to_string(&large_data).unwrap().len()
367        );
368
369        // Test 1: Direct Part::Data (BROKEN - causes scratchpad bloat)
370        let execution_result_data = ExecutionResult {
371            step_id: "test-step-1".to_string(),
372            parts: vec![Part::Data(large_data.clone())],
373            status: ExecutionStatus::Success,
374            reason: None,
375            timestamp: 1234567890,
376        };
377
378        let observation_data = execution_result_data.as_observation();
379        println!(
380            "🚨 BROKEN: Direct Part::Data observation size: {} chars",
381            observation_data.len()
382        );
383        println!(
384            "Preview (first 200 chars): {}",
385            &observation_data.chars().take(200).collect::<String>()
386        );
387
388        // Test 2: File metadata (GOOD - concise)
389        let file_metadata = crate::filesystem::FileMetadata {
390            file_id: "large-search-results.json".to_string(),
391            relative_path: "thread123/task456/large-search-results.json".to_string(),
392            size: serde_json::to_string(&large_data).unwrap().len() as u64,
393            content_type: Some("application/json".to_string()),
394            original_filename: Some("search_results.json".to_string()),
395            created_at: chrono::Utc::now(),
396            updated_at: chrono::Utc::now(),
397            checksum: Some("abc123".to_string()),
398            stats: None,
399            preview: Some("JSON search results with 100 minister entries".to_string()),
400        };
401
402        let execution_result_file = ExecutionResult {
403            step_id: "test-step-2".to_string(),
404            parts: vec![Part::Artifact(file_metadata)],
405            status: ExecutionStatus::Success,
406            reason: None,
407            timestamp: 1234567890,
408        };
409
410        let observation_file = execution_result_file.as_observation();
411        println!(
412            "āœ… GOOD: File metadata observation size: {} chars",
413            observation_file.len()
414        );
415        println!("Content: {}", observation_file);
416
417        // Demonstrate the problem
418        println!("\n=== SCRATCHPAD IMPACT ===");
419        println!(
420            "āŒ Direct approach adds {} chars to scratchpad (CAUSES LOOPS!)",
421            observation_data.len()
422        );
423        println!(
424            "āœ… File metadata adds only {} chars to scratchpad",
425            observation_file.len()
426        );
427        println!(
428            "šŸ’” Size reduction: {:.1}%",
429            (1.0 - (observation_file.len() as f64 / observation_data.len() as f64)) * 100.0
430        );
431
432        // This test shows the fix is working - observations are now truncated
433        assert!(observation_data.len() < 1000, "Large data is now truncated"); // Fixed expectation
434        assert!(
435            observation_file.len() < 300,
436            "File metadata stays reasonably concise"
437        ); // Updated for detailed format
438
439        println!("\n🚨 CONCLUSION: as_observation() needs to truncate large Part::Data!");
440    }
441
442    #[test]
443    fn test_observation_truncation_fix() {
444        println!("=== TESTING OBSERVATION TRUNCATION FIX ===");
445
446        // Test large data truncation
447        let large_data = json!({
448            "big_array": (0..200).map(|i| format!("item_{}", i)).collect::<Vec<_>>()
449        });
450
451        let execution_result = ExecutionResult {
452            step_id: "test-truncation".to_string(),
453            parts: vec![Part::Data(large_data)],
454            status: ExecutionStatus::Success,
455            reason: None,
456            timestamp: 1234567890,
457        };
458
459        let observation = execution_result.as_observation();
460        println!("Truncated observation size: {} chars", observation.len());
461        println!("Content: {}", observation);
462
463        // Should be truncated and include total char count
464        assert!(
465            observation.len() < 600,
466            "Observation should be truncated to <600 chars"
467        );
468        assert!(
469            observation.contains("truncated"),
470            "Should indicate truncation"
471        );
472        assert!(
473            observation.contains("total chars"),
474            "Should show total char count"
475        );
476
477        // Test long text truncation
478        let long_text = "This is a very long text. ".repeat(100);
479        let text_result = ExecutionResult {
480            step_id: "test-text-truncation".to_string(),
481            parts: vec![Part::Text(long_text.clone())],
482            status: ExecutionStatus::Success,
483            reason: None,
484            timestamp: 1234567890,
485        };
486
487        let text_observation = text_result.as_observation();
488        println!("Text observation size: {} chars", text_observation.len());
489        assert!(
490            text_observation.len() < 1100,
491            "Text should be truncated to ~1000 chars"
492        );
493        if long_text.len() > 1000 {
494            assert!(
495                text_observation.contains("truncated"),
496                "Long text should be truncated"
497            );
498        }
499
500        println!("āœ… Observation truncation is working!");
501    }
502
503    #[test]
504    fn test_compact_for_history_filters_save_false_and_truncates_large_parts() {
505        let mut parts_metadata = std::collections::HashMap::new();
506        parts_metadata.insert(1, crate::PartMetadata { save: false });
507
508        let tool_response = ToolResponse {
509            tool_call_id: "call-1".to_string(),
510            tool_name: "search".to_string(),
511            parts: vec![
512                Part::Data(json!({"small": "kept"})),
513                Part::Data(json!({"secret": "do not persist"})),
514            ],
515            parts_metadata: Some(parts_metadata),
516        };
517
518        let huge = "x".repeat(6_000);
519        let execution_result = ExecutionResult {
520            step_id: "step-1".to_string(),
521            parts: vec![
522                Part::Text("y".repeat(2_500)),
523                Part::Data(json!({"huge": huge})),
524                Part::ToolResult(tool_response),
525            ],
526            status: ExecutionStatus::Success,
527            reason: Some("z".repeat(2_500)),
528            timestamp: 0,
529        };
530
531        let compacted = execution_result.compact_for_history();
532
533        assert_eq!(compacted.parts.len(), 3);
534        let text = match &compacted.parts[0] {
535            Part::Text(value) => value,
536            other => panic!("unexpected part: {:?}", other),
537        };
538        assert!(text.contains("[truncated"));
539
540        let data = match &compacted.parts[1] {
541            Part::Data(value) => value,
542            other => panic!("unexpected part: {:?}", other),
543        };
544        assert_eq!(data["truncated"], json!(true));
545
546        let tool = match &compacted.parts[2] {
547            Part::ToolResult(value) => value,
548            other => panic!("unexpected part: {:?}", other),
549        };
550        // save:false part should be removed.
551        assert_eq!(tool.parts.len(), 1);
552        assert!(tool.parts_metadata.is_none());
553    }
554}