loopctl/message.rs
1//! Message types for agent conversations.
2//!
3//! Core message representation used throughout the loopctl framework for communication between users, agents, and
4//! tools. Messages consist of a [`Role`] and a list of [`MessagePart`]s,
5//! supporting plain text, base64-encoded images, tool invocations, and
6//! tool results.
7//!
8//! Messages flow through the system in a conversation history — a
9//! `Vec<Message>` that is sent to the LLM API on each turn and appended
10//! to as the conversation progresses. The API produces assistant messages
11//! (potentially with tool-call parts), and the framework responds with
12//! user-role messages containing tool results.
13//!
14//! # Provided Types
15//!
16//! - **[`Message`]** — A single message in a conversation, with a [`Role`]
17//! and zero or more [`MessagePart`]s.
18//! - **[`Role`]** — Who sent the message (`User` or `Assistant`).
19//! - **[`MessagePart`]** — A polymorphic part (text, image, tool-call, or tool-result).
20//! - **[`ImageSource`]** — Base64-encoded image data in loopctl API format.
21//! - **[`ToolContent`]** — Content returned by a tool (string or multipart).
22//! - **[`ToolContentPart`]** — A single part of a multipart tool result.
23//!
24//! # Message Flow
25//!
26//! ```text
27//! User sends text ──▶ Message::user("query")
28//! │
29//! API processes
30//! │
31//! ◀── Message::assistant("thinking...")
32//! ◀── MessagePart::ToolCall { id, name, input }
33//! │
34//! Framework executes tool
35//! │
36//! ──▶ MessagePart::ToolResult { call_id, output }
37//! │
38//! API continues
39//! ◀── Message::assistant("final answer")
40//! ```
41//!
42//! # Quick Start
43//!
44//! ```rust
45//! use loopctl::message::{Message, MessagePart, Role, ToolContent};
46//!
47//! // Create a simple user message
48//! let user_msg = Message::user("What files are in /tmp?");
49//!
50//! // Create an assistant message with a tool invocation
51//! let assistant_msg = Message {
52//! role: Role::Assistant,
53//! parts: vec![
54//! MessagePart::text("Let me check that for you."),
55//! MessagePart::tool_call("tool_1", "list_files", serde_json::json!({"path": "/tmp"})),
56//! ],
57//! };
58//!
59//! // Create a tool-result message
60//! let tool_result_msg = Message {
61//! role: Role::User, // tool results are sent back as "user" role
62//! parts: vec![
63//! MessagePart::tool_result("tool_1", ToolContent::from_string("file1.txt\nfile2.txt"), false),
64//! ],
65//! };
66//! ```
67
68use serde::{Deserialize, Serialize};
69use serde_json::Value;
70use std::fmt;
71
72// ==================================================
73// Message
74// ==================================================
75
76/// A message in the conversation.
77///
78/// Messages are the fundamental unit of communication between users,
79/// agents, and tools. Each message has a [`Role`] and a list of
80/// [`MessagePart`]s. The `parts` is a `Vec<MessagePart>` because
81/// a single assistant response can contain both text and tool-call
82/// parts.
83///
84/// # Construction
85///
86/// Use the [`user`](Message::user) and [`assistant`](Message::assistant)
87/// convenience constructors for simple text messages, or [`new`](Message::new)
88/// for messages with multiple parts.
89///
90/// # Example
91///
92/// ```rust
93/// use loopctl::message::{Message, Role};
94/// let msg = Message::user("Hello, agent!");
95/// assert_eq!(msg.role, Role::User);
96/// assert_eq!(msg.parts.len(), 1);
97/// ```
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct Message {
100 /// Who sent this message.
101 ///
102 /// Determines whether the message came from the [`User`](Role::User)
103 /// or the [`Assistant`](Role::Assistant). Tool-result messages are
104 /// typically sent with [`Role::User`] per convention.
105 pub role: Role,
106
107 /// The parts in this message.
108 ///
109 /// A message can contain multiple parts of different types — for
110 /// example, an assistant response might include a [`Text`](MessagePart::Text)
111 /// part followed by a [`ToolCall`](MessagePart::ToolCall) block.
112 /// An empty vector is valid and represents a message with no content.
113 pub parts: Vec<MessagePart>,
114}
115
116impl Message {
117 /// Create a user message from plain text.
118 ///
119 /// Convenience constructor that wraps the text in a single
120 /// [`MessagePart::Text`] part with [`Role::User`].
121 ///
122 /// # Arguments
123 ///
124 /// - `text` — The message text. Accepts any type that implements
125 /// `Into<String>` (e.g. `&str`, `String`).
126 ///
127 /// # Returns
128 ///
129 /// A [`Message`] with role [`Role::User`] and one text part.
130 ///
131 /// # Example
132 ///
133 /// ```rust
134 /// use loopctl::message::{Message, Role};
135 /// let msg = Message::user("What is the weather today?");
136 /// assert_eq!(msg.role, Role::User);
137 /// ```
138 pub fn user(text: impl Into<String>) -> Self {
139 Self {
140 role: Role::User,
141 parts: vec![MessagePart::text(text)],
142 }
143 }
144
145 /// Create an assistant message from plain text.
146 ///
147 /// Convenience constructor that wraps the text in a single
148 /// [`MessagePart::Text`] part with [`Role::Assistant`].
149 ///
150 /// # Arguments
151 ///
152 /// - `text` — The message text. Accepts any type that implements
153 /// `Into<String>` (e.g. `&str`, `String`).
154 ///
155 /// # Returns
156 ///
157 /// A [`Message`] with role [`Role::Assistant`] and one text part.
158 ///
159 /// # Example
160 ///
161 /// ```rust
162 /// use loopctl::message::{Message, Role};
163 /// let msg = Message::assistant("The weather is sunny.");
164 /// assert_eq!(msg.role, Role::Assistant);
165 /// ```
166 pub fn assistant(text: impl Into<String>) -> Self {
167 Self {
168 role: Role::Assistant,
169 parts: vec![MessagePart::text(text)],
170 }
171 }
172
173 /// Create a message with the given role and parts.
174 ///
175 /// Use this constructor when you need multiple parts
176 /// (e.g. text + tool-call in the same message).
177 ///
178 /// # Arguments
179 ///
180 /// - `role` — The [`Role`] of the message sender.
181 /// - `parts` — A vector of [`MessagePart`]s.
182 ///
183 /// # Returns
184 ///
185 /// A [`Message`] with the specified role and content.
186 ///
187 /// # Example
188 ///
189 /// ```rust
190 /// use loopctl::message::{Message, MessagePart, Role};
191 /// let msg = Message::new(Role::Assistant, vec![
192 /// MessagePart::text("Let me look that up."),
193 /// MessagePart::tool_call("t1", "search", serde_json::json!({"q": "weather"})),
194 /// ]);
195 /// ```
196 #[must_use]
197 pub const fn new(role: Role, parts: Vec<MessagePart>) -> Self {
198 Self { role, parts }
199 }
200}
201
202/// Formats a [`Message`] for display.
203///
204/// Formats each [`MessagePart`] in the message on its own line.
205/// Text parts render as-is; tool-call parts render as
206/// `[Tool: {name} with input: {input}]`; tool-result parts render
207/// as `[Tool Result: {content}]`; image parts render as
208/// `[Image: {media_type}]`.
209///
210/// For structured serialization, use [`serde_json::to_string`] instead.
211///
212/// # Example
213///
214/// ```rust
215/// use loopctl::message::{Message};
216/// let msg = Message::user("Hello");
217/// println!("{msg}"); // prints "Hello"
218/// ```
219impl fmt::Display for Message {
220 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221 let mut chunks = Vec::new();
222 for part in &self.parts {
223 match part {
224 MessagePart::Text { text } => {
225 chunks.push(text.clone());
226 }
227 MessagePart::ToolCall { name, input, .. } => {
228 if let Ok(input_str) = serde_json::to_string(input) {
229 chunks.push(format!("[Tool: {name} with input: {input_str}]"));
230 }
231 }
232 MessagePart::ToolResult { output, .. } => {
233 chunks.push(format!("[Tool Result: {output}]"));
234 }
235 MessagePart::Image { source } => {
236 chunks.push(format!("[Image: {}]", source.media_type));
237 }
238 }
239 }
240 write!(f, "{}", chunks.join("\n"))
241 }
242}
243
244// ==================================================
245// Role
246// ==================================================
247
248/// The role of a message sender in the conversation.
249///
250/// Each [`Message`] has exactly one role that identifies who produced
251/// the content. Common LLM APIs use `"user"` for human messages and
252/// tool results, and `"assistant"` for model responses.
253///
254/// # Serialization
255///
256/// Serialized as lowercase `snake_case` strings (`"user"`, `"assistant"`)
257/// to match the API convention.
258///
259/// # Example
260///
261/// ```rust
262/// use loopctl::message::{Role};
263/// assert_eq!(Role::User.to_string(), "user");
264/// assert_eq!(Role::Assistant.to_string(), "assistant");
265/// ```
266#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
267#[serde(rename_all = "snake_case")]
268pub enum Role {
269 /// A message from the human user.
270 ///
271 /// Also used for tool-result messages sent back to the API,
272 /// per convention.
273 User,
274
275 /// A message from the assistant / LLM model.
276 ///
277 /// Contains the model's generated response, which may include
278 /// text, tool-call invocations, or both.
279 Assistant,
280}
281
282/// Formats a [`Role`] as its lowercase API string.
283///
284/// This implementation is used by [`Display`](fmt::Display) to produce
285/// the string that LLM APIs expect: `"user"` or `"assistant"`.
286/// It is also used for logging and building request payloads
287/// without pulling in the serde serializer.
288///
289/// # Example
290///
291/// ```rust
292/// use loopctl::message::{Role};
293/// assert_eq!(format!("Role: {}", Role::User), "Role: user");
294/// ```
295impl fmt::Display for Role {
296 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297 match self {
298 Self::User => write!(f, "user"),
299 Self::Assistant => write!(f, "assistant"),
300 }
301 }
302}
303
304// ==================================================
305// MessagePart
306// ==================================================
307
308/// A part of message content within a [`Message`].
309///
310/// Messages can contain multiple types of content interleaved in a
311/// single response: plain text, base64-encoded images, tool-call
312/// invocations by the assistant, and tool results. Each variant
313/// corresponds to a possible part type with tagged JSON serialization.
314///
315/// # Serialization
316///
317/// Uses `#[serde(tag = "type")]` with renamed variants to produce
318/// JSON objects like `{"type": "text", "text": "..."}` matching the
319/// loopctl message schema.
320///
321/// # Construction
322///
323/// Use the constructors [`text`](MessagePart::text),
324/// [`tool_call`](MessagePart::tool_call), and
325/// [`tool_result`](MessagePart::tool_result) rather than building
326/// variants directly.
327///
328/// # Example
329///
330/// ```rust
331/// use loopctl::message::{MessagePart};
332/// let text_part = MessagePart::text("Hello");
333/// let tool_part = MessagePart::tool_call("id1", "search", serde_json::json!({"q": "test"}));
334/// let result_part = MessagePart::tool_result("id1", "found 3 results", false);
335/// ```
336#[derive(Debug, Clone, Serialize, Deserialize)]
337#[serde(tag = "type")]
338pub enum MessagePart {
339 /// Plain text content.
340 ///
341 /// Contains a UTF-8 text string generated by the model or
342 /// provided by the user.
343 #[serde(rename = "text")]
344 Text {
345 /// The text content of this part.
346 ///
347 /// May contain multiple lines. For streaming responses, this
348 /// is the fully assembled text after all streaming deltas have been concatenated.
349 text: String,
350 },
351
352 /// A base64-encoded image part.
353 ///
354 /// Images can be included in user messages for multimodal models.
355 /// The [`ImageSource`] contains the MIME type and base64 data.
356 ///
357 /// Serialized as `{"type":"image","source":{...}}`.
358 ///
359 /// Note: image parts are only valid in [`Role::User`] messages;
360 /// the API rejects them in assistant messages.
361 #[serde(rename = "image")]
362 Image {
363 /// The image data and metadata.
364 ///
365 /// Contains the MIME type, encoding type, and base64-encoded
366 /// image bytes. See [`ImageSource`] for details.
367 source: ImageSource,
368 },
369
370 /// A tool-call invocation by the assistant.
371 ///
372 /// When the model decides to call a tool, it emits a part with
373 /// the tool's ID, name, and JSON input. The framework then
374 /// executes the tool and sends the result back as a
375 /// [`ToolResult`](Self::ToolResult) block.
376 ///
377 /// The correlation between a `ToolCall` and its `ToolResult` is
378 /// done via the [`id`](Self::ToolCall.id) field, which must be
379 /// unique within a single conversation turn.
380 #[serde(rename = "tool_call")]
381 ToolCall {
382 /// Unique identifier for this tool invocation.
383 ///
384 /// Assigned by the API. Used to correlate the tool-call part
385 /// with the corresponding [`ToolResult`](Self::ToolResult)
386 /// part in the next message.
387 id: String,
388
389 /// The name of the tool being invoked.
390 ///
391 /// Must match one of the tool names provided in the request's
392 /// `tools` parameter.
393 name: String,
394
395 /// The JSON input provided by the model for the tool.
396 ///
397 /// Structure depends on the tool's input schema. May be
398 /// any JSON value (`Object`, `Array`, `String`, etc.).
399 input: Value,
400 },
401
402 /// The result of a tool-call invocation.
403 ///
404 /// Sent back to the model with [`Role::User`] (per API convention)
405 /// to provide the output of a tool execution. Contains the tool-call
406 /// ID for correlation, the output content, and an optional error flag.
407 ///
408 /// After the agent executes a tool, it wraps the output in this
409 /// variant and appends it to the conversation history so the model
410 /// can reason about the output.
411 #[serde(rename = "tool_result")]
412 ToolResult {
413 /// The ID of the tool-call part this result corresponds to.
414 ///
415 /// Must match the `id` from the original
416 /// [`ToolCall`](MessagePart::ToolCall) block.
417 call_id: String,
418
419 /// The output returned by the tool.
420 ///
421 /// Can be a simple string or a multipart response with
422 /// multiple parts. See [`ToolContent`].
423 output: ToolContent,
424
425 /// Whether this result represents an error.
426 ///
427 /// `Some(true)` indicates the tool invocation failed. When
428 /// `None` or `Some(false)`, the result is a success.
429 is_error: Option<bool>,
430 },
431}
432
433impl MessagePart {
434 /// Create a text part.
435 ///
436 /// Constructor for the [`Text`](MessagePart::Text) variant.
437 /// Accepts any type that implements `Into<String>`.
438 ///
439 /// # Arguments
440 ///
441 /// - `text` — The text content.
442 ///
443 /// # Returns
444 ///
445 /// A [`MessagePart::Text`] variant.
446 ///
447 /// # Example
448 ///
449 /// ```rust
450 /// use loopctl::message::{MessagePart};
451 /// let part = MessagePart::text("Hello, world!");
452 /// assert!(part.is_text());
453 /// ```
454 pub fn text(text: impl Into<String>) -> Self {
455 Self::Text { text: text.into() }
456 }
457
458 /// Create a tool-call part.
459 ///
460 /// Used when constructing an assistant message that invokes a tool.
461 /// The `id` must be unique within the conversation to allow
462 /// correlation with the corresponding tool result.
463 ///
464 /// # Arguments
465 ///
466 /// - `id` — Unique identifier for this tool invocation.
467 /// - `name` — The name of the tool to invoke.
468 /// - `input` — The JSON input parameters for the tool.
469 ///
470 /// # Returns
471 ///
472 /// A [`MessagePart::ToolCall`] variant.
473 ///
474 /// # Example
475 ///
476 /// ```rust
477 /// use loopctl::message::{MessagePart};
478 /// let part = MessagePart::tool_call(
479 /// "tool_abc",
480 /// "read_file",
481 /// serde_json::json!({"path": "/tmp/test.txt"}),
482 /// );
483 /// assert!(part.is_tool_call());
484 /// ```
485 pub fn tool_call(id: impl Into<String>, name: impl Into<String>, input: Value) -> Self {
486 Self::ToolCall {
487 id: id.into(),
488 name: name.into(),
489 input,
490 }
491 }
492
493 /// Create a tool-result part.
494 ///
495 /// Used when sending the output of a tool execution back to the
496 /// model. The `call_id` must match the `id` from the original
497 /// [`ToolCall`](MessagePart::ToolCall) block.
498 ///
499 /// # Arguments
500 ///
501 /// - `call_id` — The ID of the tool invocation this output is for.
502 /// - `output` — The tool output (string or multipart).
503 /// - `is_error` — Whether the tool invocation failed.
504 ///
505 /// # Returns
506 ///
507 /// A [`MessagePart::ToolResult`] variant.
508 ///
509 /// # Example
510 ///
511 /// ```rust
512 /// use loopctl::message::{MessagePart};
513 /// let part = MessagePart::tool_result(
514 /// "tool_abc",
515 /// "file contents here",
516 /// false,
517 /// );
518 /// assert!(part.is_tool_result());
519 /// ```
520 pub fn tool_result(
521 call_id: impl Into<String>,
522 output: impl Into<ToolContent>,
523 is_error: bool,
524 ) -> Self {
525 Self::ToolResult {
526 call_id: call_id.into(),
527 output: output.into(),
528 is_error: Some(is_error),
529 }
530 }
531
532 /// Returns `true` if this is a [`Text`](MessagePart::Text) block.
533 ///
534 /// Allows filtering or pattern matching on parts
535 /// without a full `match` expression.
536 ///
537 /// # Example
538 ///
539 /// ```rust
540 /// use loopctl::message::{MessagePart};
541 /// let part = MessagePart::text("hello");
542 /// assert!(part.is_text());
543 /// ```
544 #[must_use]
545 pub const fn is_text(&self) -> bool {
546 matches!(self, Self::Text { .. })
547 }
548
549 /// Returns `true` if this is a [`ToolCall`](MessagePart::ToolCall) block.
550 ///
551 /// Detects tool invocations in an assistant message
552 /// to decide whether to enter the tool-execution loop.
553 ///
554 /// # Example
555 ///
556 /// ```rust
557 /// use loopctl::message::{MessagePart};
558 /// use serde_json::Value;
559 /// let part = MessagePart::tool_call("id", "tool", Value::Null);
560 /// assert!(part.is_tool_call());
561 /// ```
562 #[must_use]
563 pub const fn is_tool_call(&self) -> bool {
564 matches!(self, Self::ToolCall { .. })
565 }
566
567 /// Returns `true` if this is a [`ToolResult`](MessagePart::ToolResult) block.
568 ///
569 /// Identifies tool results in a conversation history.
570 ///
571 /// # Example
572 ///
573 /// ```rust
574 /// use loopctl::message::{MessagePart};
575 /// let part = MessagePart::tool_result("id", "output", false);
576 /// assert!(part.is_tool_result());
577 /// ```
578 #[must_use]
579 pub const fn is_tool_result(&self) -> bool {
580 matches!(self, Self::ToolResult { .. })
581 }
582
583 /// Get the text content if this is a [`Text`](MessagePart::Text) block.
584 ///
585 /// Returns `Some(&str)` for text parts, `None` for all other
586 /// variants. Extracts the text from a known-text part.
587 ///
588 /// # Returns
589 ///
590 /// `Some(text)` if this is a text part, `None` otherwise.
591 ///
592 /// # Example
593 ///
594 /// ```rust
595 /// use loopctl::message::{MessagePart};
596 /// use serde_json::Value;
597 /// let part = MessagePart::text("hello");
598 /// assert_eq!(part.as_text(), Some("hello"));
599 ///
600 /// let tool = MessagePart::tool_call("id", "tool", Value::Null);
601 /// assert_eq!(tool.as_text(), None);
602 /// ```
603 #[must_use]
604 pub fn as_text(&self) -> Option<&str> {
605 match self {
606 Self::Text { text } => Some(text),
607 _ => None,
608 }
609 }
610}
611
612// ==================================================
613// ImageSource
614// ==================================================
615
616/// Source data for an image part in loopctl API format.
617///
618/// Represents a base64-encoded image with its MIME type. Serialized
619/// as:
620///
621/// ```json
622/// { "type": "base64", "media_type": "image/png", "data": "..." }
623/// ```
624///
625/// Use [`new_base64`](ImageSource::new_base64) to construct from
626/// a MIME type and raw base64 data.
627///
628/// # Construction
629///
630/// Always use [`new_base64`](ImageSource::new_base64) rather than
631/// building the struct directly — it ensures the `encoding` field
632/// is set correctly.
633///
634/// # Example
635///
636/// ```rust
637/// use loopctl::message::{ImageSource};
638/// let source = ImageSource::new_base64("image/png", "iVBORw0KGgo...");
639/// assert_eq!(source.media_type, "image/png");
640/// ```
641#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
642pub struct ImageSource {
643 /// The encoding type for the image data.
644 ///
645 /// LLM APIs always use `"base64"`.
646 pub encoding: String,
647
648 /// MIME type of the image (e.g. `"image/png"`, `"image/jpeg"`, `"image/webp"`).
649 ///
650 /// Must be one of the supported types for the model being used.
651 /// Used by the API to correctly decode the base64 data.
652 pub media_type: String,
653
654 /// Base64-encoded image data.
655 ///
656 /// The raw bytes of the image encoded as a base64 string. Should
657 /// not include a data-URI prefix — only the base64 payload.
658 pub data: String,
659}
660
661impl ImageSource {
662 /// Create a new base64 image source.
663 ///
664 /// Sets the `encoding` to `"base64"` automatically.
665 /// Standard construction method for image sources in loopctl API format.
666 ///
667 /// # Arguments
668 ///
669 /// - `media_type` — The MIME type (e.g. `"image/png"`).
670 /// - `data` — The base64-encoded image data.
671 ///
672 /// # Returns
673 ///
674 /// An [`ImageSource`] ready to use in an [`Image`](MessagePart::Image) block.
675 ///
676 /// # Example
677 ///
678 /// ```rust
679 /// use loopctl::message::{ImageSource};
680 /// let source = ImageSource::new_base64("image/png", "iVBORw0KGgo...");
681 /// assert_eq!(source.encoding, "base64");
682 /// ```
683 #[must_use]
684 pub fn new_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
685 Self {
686 encoding: "base64".into(),
687 media_type: media_type.into(),
688 data: data.into(),
689 }
690 }
691}
692
693// ==================================================
694// ToolContent
695// ==================================================
696
697/// Content that can be returned from a tool invocation.
698///
699/// Supports both simple string results and multipart results with
700/// multiple parts (text and images).
701///
702/// Serialized using `#[serde(untagged)]` so that a plain string
703/// is represented directly in JSON, while a multipart result
704/// is serialized as an array of [`ToolContentPart`] objects.
705///
706/// # Construction
707///
708/// Use [`from_string`](ToolContent::from_string) for simple
709/// text results or [`from_multipart`](ToolContent::from_multipart)
710/// for multi-part results. Both `String` and `&str` implement
711/// `Into<ToolContent>` for ergonomic conversion.
712///
713/// # Default
714///
715/// The [`Default`] implementation produces an empty string result,
716/// equivalent to `ToolContent::from_string("")`.
717///
718/// # Example
719///
720/// ```rust
721/// use loopctl::message::{ToolContent, ToolContentPart};
722/// // Simple string result
723/// let simple = ToolContent::from_string("File found: test.txt");
724///
725/// // Multipart result with multiple parts
726/// let multipart = ToolContent::from_multipart(vec![
727/// ToolContentPart::text("Line 1"),
728/// ToolContentPart::text("Line 2"),
729/// ]);
730/// ```
731#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
732#[serde(untagged)]
733pub enum ToolContent {
734 /// A simple string result.
735 ///
736 /// Returned by tools that produce plain text output.
737 Text(String),
738
739 /// A multipart result with multiple parts.
740 ///
741 /// Used when a tool needs to return mixed content (text + images)
742 /// or multiple text segments.
743 Multipart(Vec<ToolContentPart>),
744}
745
746impl ToolContent {
747 /// Create a simple string result.
748 ///
749 /// Wraps the given text in the [`Text`](Self::Text) variant.
750 ///
751 /// # Arguments
752 ///
753 /// - `s` — The text content of the result.
754 ///
755 /// # Returns
756 ///
757 /// A [`ToolContent::Text`] variant.
758 ///
759 /// # Example
760 ///
761 /// ```rust
762 /// use loopctl::message::{ToolContent};
763 /// let content = ToolContent::from_string("Done!");
764 /// assert!(content.is_string());
765 /// ```
766 #[must_use]
767 pub fn from_string(s: impl Into<String>) -> Self {
768 Self::Text(s.into())
769 }
770
771 /// Create a multipart result with multiple parts.
772 ///
773 /// Use this when a tool needs to return more than plain text —
774 /// for example, text combined with images.
775 ///
776 /// # Arguments
777 ///
778 /// - `parts` — The [`ToolContentPart`]s that make up the result.
779 ///
780 /// # Returns
781 ///
782 /// A [`ToolContent::Multipart`] variant.
783 ///
784 /// # Example
785 ///
786 /// ```rust
787 /// use loopctl::message::{ToolContent, ToolContentPart};
788 /// let content = ToolContent::from_multipart(vec![
789 /// ToolContentPart::text("file1.txt"),
790 /// ToolContentPart::text("file2.txt"),
791 /// ]);
792 /// ```
793 #[must_use]
794 pub const fn from_multipart(parts: Vec<ToolContentPart>) -> Self {
795 Self::Multipart(parts)
796 }
797
798 /// Returns `true` if this is a simple string result.
799 ///
800 /// Branches on how to process or display the result.
801 ///
802 /// # Example
803 ///
804 /// ```rust
805 /// use loopctl::message::{ToolContent};
806 /// let content = ToolContent::from_string("ok");
807 /// assert!(content.is_string());
808 /// ```
809 #[must_use]
810 pub const fn is_string(&self) -> bool {
811 matches!(self, Self::Text(_))
812 }
813}
814
815/// Produces a [`Default`] value for [`ToolContent`].
816///
817/// Returns an empty [`String`](ToolContent::Text) variant,
818/// which is the neutral starting point for tool results. Used when
819/// building responses incrementally or initializing result storage.
820///
821/// # Example
822///
823/// ```rust
824/// use loopctl::message::{ToolContent};
825/// let content = ToolContent::default();
826/// assert!(content.is_string());
827/// assert_eq!(content.to_string(), "");
828/// ```
829impl Default for ToolContent {
830 fn default() -> Self {
831 Self::Text(String::new())
832 }
833}
834
835/// Converts a [`String`] into a [`ToolContent::Text`].
836///
837/// This blanket conversion allows passing owned strings directly
838/// into APIs that accept [`Into<ToolContent>`], for example
839/// [`MessagePart::tool_result`].
840///
841/// # Example
842///
843/// ```rust
844/// use loopctl::message::{ToolContent};
845/// let content: ToolContent = "File found".to_string().into();
846/// assert!(content.is_string());
847/// ```
848impl From<String> for ToolContent {
849 fn from(s: String) -> Self {
850 Self::Text(s)
851 }
852}
853
854/// Converts a `&str` into a [`ToolContent::Text`].
855///
856/// This conversion allocates a new [`String`] from the borrowed slice.
857/// It enables ergonomic usage with string literals:
858///
859/// # Example
860///
861/// ```rust
862/// use loopctl::message::{ToolContent};
863/// let content: ToolContent = "ok".into();
864/// assert!(content.is_string());
865/// ```
866impl From<&str> for ToolContent {
867 fn from(s: &str) -> Self {
868 Self::Text(s.to_owned())
869 }
870}
871
872/// Formats a [`ToolContent`] for display.
873///
874/// For the [`String`](ToolContent::Text) variant, writes the
875/// text directly. For the [`Multipart`](ToolContent::Multipart)
876/// variant, concatenates all [`ToolContentPart::Text`] parts with
877/// newlines, silently skipping any non-text parts (e.g. images).
878///
879/// For quick debugging and logging, use [`Display`](std::fmt::Display).
880/// For full structured serialization, use [`serde_json::to_string`].
881///
882/// # Example
883///
884/// ```rust
885/// use loopctl::message::{ToolContent, ToolContentPart};
886/// let content = ToolContent::from_string("Done!");
887/// assert_eq!(content.to_string(), "Done!");
888///
889/// let multipart = ToolContent::from_multipart(vec![
890/// ToolContentPart::text("line 1"),
891/// ToolContentPart::text("line 2"),
892/// ]);
893/// assert_eq!(multipart.to_string(), "line 1\nline 2");
894/// ```
895impl fmt::Display for ToolContent {
896 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
897 match self {
898 Self::Text(s) => write!(f, "{s}"),
899 Self::Multipart(parts) => {
900 let texts: Vec<&str> = parts
901 .iter()
902 .filter_map(|part| {
903 if let ToolContentPart::Text { text } = part {
904 Some(text.as_str())
905 } else {
906 None
907 }
908 })
909 .collect();
910 write!(f, "{}", texts.join("\n"))
911 }
912 }
913 }
914}
915
916// ==================================================
917// ToolContentPart
918// ==================================================
919
920/// A part of a Multipart [`ToolContent`].
921///
922/// Represents a single piece of content within a multi-part tool result.
923/// Currently supports images (via [`ImageSource`]) and text.
924///
925/// # Construction
926///
927/// Use [`text`](ToolContentPart::text) or [`image`](ToolContentPart::image)
928/// constructors rather than building variants directly.
929///
930/// # Example
931///
932/// ```rust
933/// use loopctl::message::{ImageSource, ToolContent, ToolContentPart};
934/// let text_part = ToolContentPart::text("Screenshot analysis:");
935/// let img_part = ToolContentPart::image(ImageSource::new_base64("image/png", "..."));
936/// let content = ToolContent::from_multipart(vec![text_part, img_part]);
937/// ```
938#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
939#[serde(tag = "type", rename_all = "snake_case")]
940pub enum ToolContentPart {
941 /// An image part within a Multipart tool result.
942 ///
943 /// Contains the base64-encoded image data. Used by tools
944 /// that capture screenshots or generate images.
945 Image {
946 /// The image data and metadata.
947 ///
948 /// See [`ImageSource`] for the structure and supported formats.
949 /// The [`ImageSource::encoding`] will always be `"base64"`.
950 source: ImageSource,
951 },
952
953 /// A text part within a Multipart tool result.
954 ///
955 /// Contains a plain text string.
956 Text {
957 /// The text content of this part.
958 ///
959 /// May contain multiple lines. Joined with other text parts
960 /// by [`ToolContent`]'s [`Display`](fmt::Display) impl.
961 text: String,
962 },
963}
964
965impl ToolContentPart {
966 /// Create a text part.
967 ///
968 /// Constructor for the [`Text`](ToolContentPart::Text) variant.
969 /// Use to build individual text segments within a
970 /// [`ToolContent::Multipart`] result.
971 ///
972 /// # Arguments
973 ///
974 /// - `text` — The text content. Accepts any type that implements
975 /// `Into<String>` (e.g. `&str`, `String`).
976 ///
977 /// # Returns
978 ///
979 /// A [`ToolContentPart::Text`] variant.
980 ///
981 /// # Example
982 ///
983 /// ```rust
984 /// use loopctl::message::{ToolContentPart};
985 /// let part = ToolContentPart::text("result: 42");
986 /// ```
987 pub fn text(text: impl Into<String>) -> Self {
988 Self::Text { text: text.into() }
989 }
990
991 /// Create an image part.
992 ///
993 /// Use when a tool result includes image data alongside text.
994 /// Combine with [`ToolContentPart::text`] parts via
995 /// [`ToolContent::from_multipart`] to produce a multi-part
996 /// result containing both text and images.
997 ///
998 /// # Arguments
999 ///
1000 /// - `source` — The [`ImageSource`] containing the image data.
1001 /// Construct with [`ImageSource::new_base64`].
1002 ///
1003 /// # Returns
1004 ///
1005 /// A [`ToolContentPart::Image`] variant.
1006 ///
1007 /// # Example
1008 ///
1009 /// ```rust
1010 /// use loopctl::message::{ImageSource, ToolContentPart};
1011 /// let part = ToolContentPart::image(ImageSource::new_base64("image/png", "..."));
1012 /// ```
1013 #[must_use]
1014 pub const fn image(source: ImageSource) -> Self {
1015 Self::Image { source }
1016 }
1017}
1018
1019// ==================================================
1020// Tests
1021// ==================================================
1022
1023#[cfg(test)]
1024mod tests {
1025 use super::*;
1026
1027 #[test]
1028 fn test_message_user_shortcut() {
1029 let msg = Message::user("Hello");
1030 assert_eq!(msg.role, Role::User);
1031 assert_eq!(msg.parts.len(), 1);
1032 assert_eq!(msg.parts[0].as_text(), Some("Hello"));
1033 }
1034
1035 #[test]
1036 fn test_message_assistant_shortcut() {
1037 let msg = Message::assistant("Hi there!");
1038 assert_eq!(msg.role, Role::Assistant);
1039 assert_eq!(msg.parts.len(), 1);
1040 }
1041
1042 #[test]
1043 fn test_message_display() {
1044 let msg = Message::user("Hello world");
1045 assert_eq!(msg.to_string(), "Hello world");
1046 }
1047
1048 #[test]
1049 fn test_message_display_with_tool_call() {
1050 let msg = Message {
1051 role: Role::Assistant,
1052 parts: vec![MessagePart::tool_call(
1053 "id1",
1054 "read_file",
1055 serde_json::json!({"path": "/tmp/test.txt"}),
1056 )],
1057 };
1058 let display = msg.to_string();
1059 assert!(display.contains("Tool: read_file"));
1060 }
1061
1062 #[test]
1063 fn test_role_display() {
1064 assert_eq!(Role::User.to_string(), "user");
1065 assert_eq!(Role::Assistant.to_string(), "assistant");
1066 }
1067
1068 #[test]
1069 fn test_part_helpers() {
1070 let text = MessagePart::text("hello");
1071 assert!(text.is_text());
1072 assert!(!text.is_tool_call());
1073 assert_eq!(text.as_text(), Some("hello"));
1074
1075 let tool_call = MessagePart::tool_call("id", "tool", serde_json::json!({}));
1076 assert!(tool_call.is_tool_call());
1077 assert!(!tool_call.is_text());
1078 assert!(tool_call.as_text().is_none());
1079
1080 let tool_result = MessagePart::tool_result("id", "ok", false);
1081 assert!(tool_result.is_tool_result());
1082 }
1083
1084 #[test]
1085 fn test_image_source() {
1086 let src = ImageSource::new_base64("image/png", "iVBOR...");
1087 assert_eq!(src.encoding, "base64");
1088 assert_eq!(src.media_type, "image/png");
1089 }
1090
1091 #[test]
1092 fn test_tool_result_from_string() {
1093 let result: ToolContent = "hello".into();
1094 assert!(result.is_string());
1095 assert_eq!(result.to_string(), "hello");
1096 }
1097
1098 #[test]
1099 fn test_tool_result_default() {
1100 let result = ToolContent::default();
1101 assert!(result.is_string());
1102 }
1103
1104 #[test]
1105 fn test_tool_result_part_text() {
1106 let part = ToolContentPart::text("output");
1107 match &part {
1108 ToolContentPart::Text { text } => assert_eq!(text, "output"),
1109 ToolContentPart::Image { .. } => panic!("expected text part"),
1110 }
1111 }
1112
1113 #[test]
1114 fn test_message_serialization() {
1115 let msg = Message::user("test");
1116 let json = serde_json::to_string(&msg).unwrap();
1117 let deserialized: Message = serde_json::from_str(&json).unwrap();
1118 assert_eq!(msg.role, deserialized.role);
1119 }
1120
1121 #[test]
1122 fn test_part_serialization_roundtrip() {
1123 let parts = vec![
1124 MessagePart::text("hello"),
1125 MessagePart::tool_call("id1", "my_tool", serde_json::json!({"key": "value"})),
1126 ];
1127 let json = serde_json::to_string(&parts).unwrap();
1128 let back: Vec<MessagePart> = serde_json::from_str(&json).unwrap();
1129 assert_eq!(parts.len(), back.len());
1130 }
1131
1132 #[test]
1133 fn test_tool_result_multipart_display() {
1134 let result = ToolContent::from_multipart(vec![
1135 ToolContentPart::text("line 1"),
1136 ToolContentPart::text("line 2"),
1137 ]);
1138 assert_eq!(result.to_string(), "line 1\nline 2");
1139 }
1140}