1use serde::{Deserialize, Serialize};
4
5use crate::provider::ProviderProgress;
6use crate::tool::ToolProvenance;
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10#[serde(rename_all = "snake_case")]
11pub enum SystemCacheType {
12 Ephemeral,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub struct SystemCacheMarker {
19 pub offset: usize,
21 pub length: usize,
23 pub cache_type: SystemCacheType,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
29pub struct ToolCall {
30 pub id: String,
32 pub name: String,
34 pub input: serde_json::Value,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40pub struct MessageToolResult {
41 pub tool_use_id: String,
43 pub content: String,
45 pub is_error: bool,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
53#[serde(tag = "kind", rename_all = "snake_case")]
54pub enum ReasoningBlock {
55 Thinking {
59 text: String,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 signature: Option<String>,
62 },
63 Redacted { data: String },
66 Plain { text: String },
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73pub struct AssistantReasoning {
74 pub provider: String,
76 pub model: String,
78 pub blocks: Vec<ReasoningBlock>,
80}
81
82#[derive(Debug, Clone, Default, PartialEq, Eq)]
101pub struct ContentDigest([u8; 32]);
102
103impl ContentDigest {
104 pub const fn from_raw(bytes: [u8; 32]) -> Self {
106 Self(bytes)
107 }
108
109 pub const fn as_bytes(&self) -> &[u8; 32] {
111 &self.0
112 }
113
114 pub fn to_hex(&self) -> String {
116 let mut out = String::with_capacity(64);
117 for byte in self.0 {
118 out.push_str(&format!("{byte:02x}"));
119 }
120 out
121 }
122
123 pub fn from_hex(s: &str) -> Result<Self, String> {
125 if s.len() != 64 {
126 return Err(format!(
127 "content_digest must be 64 hex chars, got {}",
128 s.len()
129 ));
130 }
131 let mut bytes = [0u8; 32];
132 for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
133 let hex = std::str::from_utf8(chunk).map_err(|e| e.to_string())?;
134 bytes[i] = u8::from_str_radix(hex, 16).map_err(|e| e.to_string())?;
135 }
136 Ok(Self(bytes))
137 }
138}
139
140impl std::fmt::Display for ContentDigest {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 f.write_str(&self.to_hex())
143 }
144}
145
146impl serde::Serialize for ContentDigest {
147 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
148 s.serialize_str(&self.to_hex())
149 }
150}
151
152impl<'de> serde::Deserialize<'de> for ContentDigest {
153 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
154 let s = String::deserialize(d)?;
155 Self::from_hex(&s).map_err(serde::de::Error::custom)
156 }
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
166#[serde(tag = "type", rename_all = "snake_case")]
167pub enum ContentPart {
168 Text {
169 text: String,
170 },
171 Image {
172 path: std::path::PathBuf,
173 mime: String,
174 byte_count: u64,
175 #[serde(default)]
180 content_digest: ContentDigest,
181 },
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
186#[serde(tag = "role", rename_all = "snake_case")]
187pub enum Message {
188 System {
190 content: String,
192 #[serde(default, skip_serializing_if = "Vec::is_empty")]
194 cache_markers: Vec<SystemCacheMarker>,
195 },
196 Context {
198 content: String,
200 },
201 User {
203 content: String,
205 },
206 Multimodal {
212 parts: Vec<ContentPart>,
215 },
216 Assistant {
218 content: String,
220 #[serde(default, skip_serializing_if = "Vec::is_empty")]
222 tool_calls: Vec<ToolCall>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
227 reasoning: Option<AssistantReasoning>,
228 },
229 Tool {
231 result: MessageToolResult,
233 },
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
238#[serde(rename_all = "snake_case")]
239pub enum StopReason {
240 EndTurn,
242 ToolUse,
244 MaxTokens,
246}
247
248#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
250pub struct Usage {
251 pub input_tokens: u32,
253 pub output_tokens: u32,
255 #[serde(default)]
257 pub cache_read_tokens: u32,
258 #[serde(default)]
260 pub cache_write_tokens: u32,
261 #[serde(default)]
263 pub reasoning_tokens: u32,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
268#[serde(tag = "type", rename_all = "snake_case")]
269#[non_exhaustive]
270pub enum AgentEvent {
271 TurnStart,
273 ProviderProgress {
275 progress: ProviderProgress,
277 },
278 TextDelta {
280 delta: String,
282 },
283 ThinkingDelta {
288 delta: String,
290 },
291 ReasoningComplete {
294 blocks: Vec<ReasoningBlock>,
296 },
297 ToolCallStarted {
299 name: String,
301 },
302 ToolCall {
304 call: ToolCall,
306 provenance: ToolProvenance,
308 summary_fields: Vec<String>,
310 },
311 ToolResult {
313 result: MessageToolResult,
315 },
316 TurnEnd {
318 stop_reason: StopReason,
320 usage: Usage,
322 },
323 Error {
325 message: String,
327 },
328}
329
330#[cfg(test)]
331#[allow(warnings)]
332#[allow(warnings)]
333#[allow(warnings)]
334#[allow(warnings)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn message_roundtrip_user() {
340 let msg = Message::User {
341 content: "Hello, world!".into(),
342 };
343 let json = serde_json::to_string(&msg).expect("operation should succeed");
344 let decoded: Message = serde_json::from_str(&json).expect("operation should succeed");
345 assert_eq!(msg, decoded);
346 }
347
348 #[test]
349 fn message_roundtrip_assistant() {
350 let msg = Message::Assistant {
351 content: "I can help with that.".into(),
352 tool_calls: vec![ToolCall {
353 id: "call_1".into(),
354 name: "read_file".into(),
355 input: serde_json::json!({"path": "src/main.rs"}),
356 }],
357 reasoning: None,
358 };
359 let json = serde_json::to_string(&msg).expect("operation should succeed");
360 let decoded: Message = serde_json::from_str(&json).expect("operation should succeed");
361 assert_eq!(msg, decoded);
362 }
363
364 #[test]
365 fn message_roundtrip_tool() {
366 let msg = Message::Tool {
367 result: MessageToolResult {
368 tool_use_id: "call_1".into(),
369 content: "fn main() {}".into(),
370 is_error: false,
371 },
372 };
373 let json = serde_json::to_string(&msg).expect("operation should succeed");
374 let decoded: Message = serde_json::from_str(&json).expect("operation should succeed");
375 assert_eq!(msg, decoded);
376 }
377
378 #[test]
379 fn event_roundtrip() {
380 let events = vec![
381 AgentEvent::TurnStart,
382 AgentEvent::ProviderProgress {
383 progress: ProviderProgress::RetryDispatch {
384 attempt: 1,
385 max_attempts: 3,
386 },
387 },
388 AgentEvent::TextDelta {
389 delta: "Hello".into(),
390 },
391 AgentEvent::ToolCall {
392 call: ToolCall {
393 id: "c1".into(),
394 name: "bash".into(),
395 input: serde_json::json!({"command": "ls"}),
396 },
397 provenance: ToolProvenance::Native,
398 summary_fields: vec![],
399 },
400 AgentEvent::ToolResult {
401 result: MessageToolResult {
402 tool_use_id: "c1".into(),
403 content: "file.rs".into(),
404 is_error: false,
405 },
406 },
407 AgentEvent::TurnEnd {
408 stop_reason: StopReason::EndTurn,
409 usage: Usage {
410 input_tokens: 100,
411 output_tokens: 50,
412 cache_read_tokens: 80,
413 cache_write_tokens: 20,
414 reasoning_tokens: 0,
415 },
416 },
417 AgentEvent::Error {
418 message: "something failed".into(),
419 },
420 ];
421 for event in events {
422 let json = serde_json::to_string(&event).expect("operation should succeed");
423 let decoded: AgentEvent =
424 serde_json::from_str(&json).expect("operation should succeed");
425 assert_eq!(event, decoded);
426 }
427 }
428
429 #[test]
430 fn extract_tool_calls_preserves_id_from_json_tool_block() {
431 let text = r#"I'll run that for you.
432```json-tool
433{"id":"call_abc123","args":{"command":"ls"},"name":"bash"}
434```
435Done."#;
436 let calls = extract_tool_calls_from_text(text);
437 assert_eq!(calls.len(), 1);
438 assert_eq!(calls[0].id, "call_abc123");
439 assert_eq!(calls[0].name, "bash");
440 assert_eq!(calls[0].input, serde_json::json!({"command": "ls"}));
441 }
442
443 #[test]
444 fn extract_tool_calls_falls_back_to_synthetic_id_when_missing() {
445 let text = r#"```json-tool
446{"args":{"command":"ls"},"name":"bash"}
447```"#;
448 let calls = extract_tool_calls_from_text(text);
449 assert_eq!(calls.len(), 1);
450 assert_eq!(calls[0].id, "tc_0");
451 assert_eq!(calls[0].name, "bash");
452 }
453
454 #[test]
455 fn extract_tool_calls_falls_back_when_id_is_empty() {
456 let text = r#"```json-tool
457{"id":"","args":{"command":"ls"},"name":"bash"}
458```"#;
459 let calls = extract_tool_calls_from_text(text);
460 assert_eq!(calls.len(), 1);
461 assert_eq!(calls[0].id, "tc_0");
462 }
463
464 #[test]
465 fn content_part_text_roundtrip() {
466 let part = ContentPart::Text {
467 text: "Hello, image!".into(),
468 };
469 let json = serde_json::to_string(&part).expect("operation should succeed");
470 let decoded: ContentPart = serde_json::from_str(&json).expect("operation should succeed");
471 assert_eq!(part, decoded);
472 }
473
474 #[test]
475 fn content_part_image_roundtrip() {
476 let part = ContentPart::Image {
477 path: "/tmp/test.png".into(),
478 mime: "image/png".into(),
479 byte_count: 12345,
480 content_digest: ContentDigest::from_raw([7u8; 32]),
481 };
482 let json = serde_json::to_string(&part).expect("operation should succeed");
483 let decoded: ContentPart = serde_json::from_str(&json).expect("operation should succeed");
484 assert_eq!(part, decoded);
485 }
486
487 #[test]
488 fn message_multimodal_roundtrip() {
489 let msg = Message::Multimodal {
490 parts: vec![
491 ContentPart::Text {
492 text: "What is in this image?".into(),
493 },
494 ContentPart::Image {
495 path: "/tmp/screenshot.png".into(),
496 mime: "image/png".into(),
497 byte_count: 67890,
498 content_digest: ContentDigest::from_raw([9u8; 32]),
499 },
500 ],
501 };
502 let json = serde_json::to_string(&msg).expect("operation should succeed");
503 let decoded: Message = serde_json::from_str(&json).expect("operation should succeed");
504 assert_eq!(msg, decoded);
505 }
506
507 #[test]
508 fn message_user_still_works_after_multimodal_addition() {
509 let msg = Message::User {
510 content: "text only".into(),
511 };
512 let json = serde_json::to_string(&msg).expect("operation should succeed");
513 let decoded: Message = serde_json::from_str(&json).expect("operation should succeed");
514 assert_eq!(msg, decoded);
515 }
516}
517
518pub fn extract_tool_calls_from_text(text: &str) -> Vec<ToolCall> {
519 let mut calls = Vec::new();
520 let mut remaining = text;
521
522 while let Some(start) = remaining.find("```json-tool") {
523 let inner_start = start + "```json-tool".len();
524 let inner = remaining[inner_start..].trim_start();
525 let end = inner.find("```").unwrap_or(inner.len());
526 let content = inner[..end].trim();
527
528 if let Ok(obj) = serde_json::from_str::<serde_json::Value>(content)
529 && let (Some(name), Some(args)) = (obj["name"].as_str(), Some(obj["args"].clone()))
530 {
531 let id = obj["id"]
532 .as_str()
533 .filter(|s| !s.is_empty())
534 .map(String::from)
535 .unwrap_or_else(|| format!("tc_{}", calls.len()));
536 calls.push(ToolCall {
537 id,
538 name: name.to_string(),
539 input: args,
540 });
541 }
542
543 remaining = &inner[end..];
544 if end + 3 < remaining.len() {
545 remaining = &remaining[3..];
546 } else {
547 break;
548 }
549 }
550
551 calls
552}
553
554pub fn strip_tool_syntax(text: &str) -> String {
555 let mut result = text.to_string();
556 while let Some(start) = result.find("```json-tool") {
557 let inner_start = start + "```json-tool".len();
558 let inner = &result[inner_start..];
559 let end = inner_start + inner.find("```").unwrap_or(inner.len()) + 3;
560 result.replace_range(start..end, "");
561 }
562 result.trim().to_string()
563}
564
565pub fn project_displayable_reasoning(ar: &AssistantReasoning) -> Option<String> {
566 let mut parts = Vec::new();
567 for block in &ar.blocks {
568 match block {
569 ReasoningBlock::Thinking { text, .. } if !text.is_empty() => parts.push(text.clone()),
570 ReasoningBlock::Plain { text } if !text.is_empty() => parts.push(text.clone()),
571 _ => {}
572 }
573 }
574 if parts.is_empty() {
575 None
576 } else {
577 Some(parts.join("\n"))
578 }
579}