1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use serde_json::json;
4
5use crate::{core::FileType, Part, PlanStep, TaskStatus, ToolResponse};
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 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 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 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 {
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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct ExecutionHistoryEntry {
296 pub thread_id: String, pub task_id: String, pub run_id: String, pub execution_result: ExecutionResult,
300 pub stored_at: i64, }
302
303#[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#[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 #[serde(rename = "summary")]
327 Summary(CompactionSummary),
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct CompactionSummary {
333 pub summary_text: String,
335 pub entries_summarized: usize,
337 pub from_timestamp: i64,
339 pub to_timestamp: i64,
340 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 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 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 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 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 assert!(observation_data.len() < 1000, "Large data is now truncated"); assert!(
435 observation_file.len() < 300,
436 "File metadata stays reasonably concise"
437 ); 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 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 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 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 assert_eq!(tool.parts.len(), 1);
552 assert!(tool.parts_metadata.is_none());
553 }
554}