Skip to main content

ag_protocol/
prompt.rs

1//! Transport-neutral prompt payloads shared by frontends and agent adapters.
2
3use std::fmt;
4use std::path::PathBuf;
5
6/// One local image attachment referenced from a prompt placeholder.
7#[derive(Debug, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
8pub struct TurnPromptAttachment {
9    /// Inline placeholder token such as `[Image #1]` used in prompt text.
10    pub placeholder: String,
11    /// Local file path persisted for transport upload.
12    pub local_image_path: PathBuf,
13}
14
15/// Structured prompt payload for one agent turn.
16#[derive(Debug, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
17pub struct TurnPrompt {
18    /// Ordered local image attachments referenced by `text`.
19    pub attachments: Vec<TurnPromptAttachment>,
20    /// Prompt text payload, including inline placeholders when present.
21    pub text: String,
22    /// Source classification that controls prompt-only text rewrites.
23    #[serde(default)]
24    pub text_source: TurnPromptTextSource,
25}
26
27/// Source classification for one turn prompt text payload.
28#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
29pub enum TurnPromptTextSource {
30    /// User-authored prompt text where `@path` lookup tokens should be
31    /// converted before transport delivery.
32    #[default]
33    UserPrompt,
34    /// Agent-facing generated data such as utility prompts, protocol repair
35    /// prompts, diffs, and transcript previews that must be sent unchanged.
36    AgentData,
37}
38
39/// Ordered content piece produced when serializing one turn prompt.
40#[derive(Debug, Clone, Copy, Eq, PartialEq)]
41pub enum TurnPromptContentPart<'prompt> {
42    /// One local image attachment referenced from the prompt text.
43    Attachment(&'prompt TurnPromptAttachment),
44    /// One attachment whose placeholder no longer appears in the prompt text.
45    OrphanAttachment(&'prompt TurnPromptAttachment),
46    /// One plain-text span from the prompt.
47    Text(&'prompt str),
48}
49
50impl TurnPrompt {
51    /// Creates a text-only prompt payload.
52    #[must_use]
53    pub fn from_text(text: String) -> Self {
54        Self {
55            attachments: Vec::new(),
56            text,
57            text_source: TurnPromptTextSource::UserPrompt,
58        }
59    }
60
61    /// Creates a generated-data prompt payload that is sent to the agent
62    /// without user prompt `@path` lookup rewriting.
63    #[must_use]
64    pub fn from_agent_data(text: String) -> Self {
65        Self {
66            attachments: Vec::new(),
67            text,
68            text_source: TurnPromptTextSource::AgentData,
69        }
70    }
71
72    /// Returns whether the payload contains no text and no attachments.
73    #[must_use]
74    pub fn is_empty(&self) -> bool {
75        self.text.is_empty() && self.attachments.is_empty()
76    }
77
78    /// Returns whether the payload contains one or more image attachments.
79    #[must_use]
80    pub fn has_attachments(&self) -> bool {
81        !self.attachments.is_empty()
82    }
83
84    /// Returns the local image paths referenced by this prompt payload.
85    pub fn local_image_paths(&self) -> impl Iterator<Item = &PathBuf> {
86        self.attachments
87            .iter()
88            .map(|attachment| &attachment.local_image_path)
89    }
90
91    /// Returns whether the prompt text contains `needle`.
92    #[must_use]
93    pub fn contains(&self, needle: &str) -> bool {
94        self.text.contains(needle)
95    }
96
97    /// Returns whether the prompt text ends with `suffix`.
98    #[must_use]
99    pub fn ends_with(&self, suffix: &str) -> bool {
100        self.text.ends_with(suffix)
101    }
102
103    /// Returns the prompt text as it should be sent to an agent runtime.
104    ///
105    /// User-authored `@path` lookups are rewritten to quoted path tokens for
106    /// transport. Generated agent data is returned unchanged so source content
107    /// such as Python decorators keeps its leading `@`.
108    #[must_use]
109    pub fn agent_text(&self) -> String {
110        match self.text_source {
111            TurnPromptTextSource::UserPrompt => render_prompt_text_for_agent(&self.text),
112            TurnPromptTextSource::AgentData => self.text.clone(),
113        }
114    }
115
116    /// Returns prompt text spans and image attachments in transport order.
117    ///
118    /// Attachments are ordered by their placeholder position in `text`. Any
119    /// attachment whose placeholder no longer exists in the text is appended
120    /// after the trailing text so transports can still serialize the image
121    /// instead of dropping it silently.
122    #[must_use]
123    pub fn content_parts(&self) -> Vec<TurnPromptContentPart<'_>> {
124        split_turn_prompt_content(&self.text, &self.attachments)
125    }
126
127    /// Returns the prompt text as it should be written into persisted
128    /// transcripts.
129    ///
130    /// Inline `[Image #n]` markers are preserved verbatim. If attachment
131    /// metadata somehow survives without its placeholder still present in the
132    /// text, the missing placeholders are appended in attachment order so the
133    /// transcript does not silently become text-only.
134    #[must_use]
135    pub fn transcript_text(&self) -> String {
136        let mut transcript_text = self.text.clone();
137        let missing_placeholders = self
138            .attachments
139            .iter()
140            .filter(|attachment| !self.text.contains(&attachment.placeholder))
141            .map(|attachment| attachment.placeholder.as_str())
142            .collect::<Vec<_>>();
143
144        if missing_placeholders.is_empty() {
145            return transcript_text;
146        }
147
148        if transcript_text
149            .chars()
150            .last()
151            .is_some_and(|character| !character.is_whitespace())
152        {
153            transcript_text.push(' ');
154        }
155
156        transcript_text.push_str(&missing_placeholders.join(" "));
157
158        transcript_text
159    }
160}
161
162/// Splits prompt text into ordered text and attachment parts for transport
163/// serialization.
164#[must_use]
165pub fn split_turn_prompt_content<'prompt>(
166    text: &'prompt str,
167    attachments: &'prompt [TurnPromptAttachment],
168) -> Vec<TurnPromptContentPart<'prompt>> {
169    if attachments.is_empty() {
170        return vec![TurnPromptContentPart::Text(text)];
171    }
172
173    let mut ordered_attachments = attachments.iter().collect::<Vec<_>>();
174    ordered_attachments
175        // Attachments without an inline placeholder are appended after the
176        // text-bearing attachments so transcript order stays deterministic.
177        .sort_by_key(|attachment| text.find(&attachment.placeholder).unwrap_or(usize::MAX));
178
179    let mut content_parts = Vec::new();
180    let mut orphan_attachments = Vec::new();
181    let mut remaining_text = text;
182
183    for attachment in ordered_attachments {
184        if let Some(placeholder_index) = remaining_text.find(&attachment.placeholder) {
185            let (before_placeholder, after_placeholder) =
186                remaining_text.split_at(placeholder_index);
187
188            if !before_placeholder.is_empty() {
189                content_parts.push(TurnPromptContentPart::Text(before_placeholder));
190            }
191
192            content_parts.push(TurnPromptContentPart::Attachment(attachment));
193            remaining_text = &after_placeholder[attachment.placeholder.len()..];
194
195            continue;
196        }
197
198        orphan_attachments.push(attachment);
199    }
200
201    if !remaining_text.is_empty() {
202        content_parts.push(TurnPromptContentPart::Text(remaining_text));
203    }
204
205    content_parts.extend(
206        orphan_attachments
207            .into_iter()
208            .map(TurnPromptContentPart::OrphanAttachment),
209    );
210
211    content_parts
212}
213
214/// Rewrites user-entered `@` lookups into quoted agent-facing path tokens.
215///
216/// File and directory lookups like `@path/to/file` are rewritten to
217/// `"path/to/file"`. Non-lookup uses of `@`, including email-like tokens and
218/// lone `@`, are preserved so frontends and persisted transcripts can continue
219/// storing the original user input unchanged.
220#[must_use]
221pub fn render_prompt_text_for_agent(text: &str) -> String {
222    let characters = text.chars().collect::<Vec<char>>();
223    let mut output = String::with_capacity(text.len());
224    let mut index = 0;
225
226    while let Some(&character) = characters.get(index) {
227        if character != '@'
228            || !is_at_mention_boundary(characters.get(index.wrapping_sub(1)).copied())
229            || index + 1 >= characters.len()
230        {
231            output.push(character);
232            index += 1;
233
234            continue;
235        }
236
237        let mut scan_index = index + 1;
238        while scan_index < characters.len() && is_at_mention_query_character(characters[scan_index])
239        {
240            scan_index += 1;
241        }
242
243        if scan_index == index + 1 {
244            output.push(character);
245            index += 1;
246
247            continue;
248        }
249
250        output.push('"');
251        output.extend(characters[index + 1..scan_index].iter());
252        output.push('"');
253        index = scan_index;
254    }
255
256    output
257}
258
259/// Returns whether the character before `@` starts a file lookup token.
260fn is_at_mention_boundary(previous_character: Option<char>) -> bool {
261    previous_character.is_none_or(|character| {
262        character.is_whitespace() || is_at_mention_opening_delimiter(character)
263    })
264}
265
266/// Returns whether `character` can appear inside an active `@` lookup token.
267fn is_at_mention_query_character(character: char) -> bool {
268    character.is_alphanumeric() || matches!(character, '/' | '.' | '_' | '-')
269}
270
271/// Returns whether `character` is an opening delimiter that can precede `@`.
272fn is_at_mention_opening_delimiter(character: char) -> bool {
273    matches!(character, '(' | '[' | '{')
274}
275
276impl From<String> for TurnPrompt {
277    fn from(text: String) -> Self {
278        Self::from_text(text)
279    }
280}
281
282impl From<&str> for TurnPrompt {
283    fn from(text: &str) -> Self {
284        Self::from_text(text.to_string())
285    }
286}
287
288impl From<&TurnPrompt> for TurnPrompt {
289    fn from(prompt: &TurnPrompt) -> Self {
290        prompt.clone()
291    }
292}
293
294impl fmt::Display for TurnPrompt {
295    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
296        formatter.write_str(&self.text)
297    }
298}
299
300impl PartialEq<&str> for TurnPrompt {
301    fn eq(&self, other: &&str) -> bool {
302        self.text == *other
303    }
304}
305
306impl PartialEq<TurnPrompt> for &str {
307    fn eq(&self, other: &TurnPrompt) -> bool {
308        *self == other.text
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use std::path::PathBuf;
315
316    use super::*;
317
318    #[test]
319    /// Ensures transcript text keeps inline image placeholders unchanged.
320    fn test_turn_prompt_transcript_text_keeps_inline_placeholders() {
321        // Arrange
322        let prompt = TurnPrompt {
323            attachments: vec![TurnPromptAttachment {
324                placeholder: "[Image #1]".to_string(),
325                local_image_path: PathBuf::from("/tmp/image-1.png"),
326            }],
327            text: "Review [Image #1] carefully".to_string(),
328            text_source: TurnPromptTextSource::UserPrompt,
329        };
330
331        // Act
332        let transcript_text = prompt.transcript_text();
333
334        // Assert
335        assert_eq!(transcript_text, "Review [Image #1] carefully");
336    }
337
338    #[test]
339    /// Ensures agent-bound prompt text rewrites raw `@` lookups without
340    /// mutating transcript-facing text.
341    fn test_turn_prompt_agent_text_rewrites_user_at_lookups() {
342        // Arrange
343        let prompt = TurnPrompt::from("Review @src/main.rs and person@example.com");
344
345        // Act
346        let agent_text = prompt.agent_text();
347        let transcript_text = prompt.transcript_text();
348
349        // Assert
350        assert_eq!(agent_text, "Review \"src/main.rs\" and person@example.com");
351        assert_eq!(
352            transcript_text,
353            "Review @src/main.rs and person@example.com"
354        );
355    }
356
357    #[test]
358    /// Ensures generated agent data bypasses user prompt `@` lookup rewriting.
359    fn test_turn_prompt_agent_text_preserves_agent_data_at_tokens() {
360        // Arrange
361        let prompt = TurnPrompt::from_agent_data(
362            "Diff:\n```diff\n+@dataclass\n+class Config:\n+    pass\n```".to_string(),
363        );
364
365        // Act
366        let agent_text = prompt.agent_text();
367
368        // Assert
369        assert!(agent_text.contains("+@dataclass"));
370        assert!(!agent_text.contains("+\"dataclass\""));
371    }
372
373    #[test]
374    /// Ensures transcript text appends any attachment markers missing from the
375    /// text payload.
376    fn test_turn_prompt_transcript_text_appends_missing_placeholders() {
377        // Arrange
378        let prompt = TurnPrompt {
379            attachments: vec![
380                TurnPromptAttachment {
381                    placeholder: "[Image #1]".to_string(),
382                    local_image_path: PathBuf::from("/tmp/image-1.png"),
383                },
384                TurnPromptAttachment {
385                    placeholder: "[Image #2]".to_string(),
386                    local_image_path: PathBuf::from("/tmp/image-2.png"),
387                },
388            ],
389            text: "Review".to_string(),
390            text_source: TurnPromptTextSource::UserPrompt,
391        };
392
393        // Act
394        let transcript_text = prompt.transcript_text();
395
396        // Assert
397        assert_eq!(transcript_text, "Review [Image #1] [Image #2]");
398    }
399
400    #[test]
401    /// Ensures prompt content parts follow placeholder order and keep orphaned
402    /// attachments at the end.
403    fn test_split_turn_prompt_content_orders_placeholders_and_appends_orphans() {
404        // Arrange
405        let attachments = vec![
406            TurnPromptAttachment {
407                placeholder: "[Image #1]".to_string(),
408                local_image_path: PathBuf::from("/tmp/image-1.png"),
409            },
410            TurnPromptAttachment {
411                placeholder: "[Image #2]".to_string(),
412                local_image_path: PathBuf::from("/tmp/image-2.png"),
413            },
414            TurnPromptAttachment {
415                placeholder: "[Image #3]".to_string(),
416                local_image_path: PathBuf::from("/tmp/image-3.png"),
417            },
418        ];
419
420        // Act
421        let content_parts =
422            split_turn_prompt_content("Compare [Image #2] with [Image #1] now", &attachments);
423
424        // Assert
425        assert_eq!(
426            content_parts,
427            vec![
428                TurnPromptContentPart::Text("Compare "),
429                TurnPromptContentPart::Attachment(&attachments[1]),
430                TurnPromptContentPart::Text(" with "),
431                TurnPromptContentPart::Attachment(&attachments[0]),
432                TurnPromptContentPart::Text(" now"),
433                TurnPromptContentPart::OrphanAttachment(&attachments[2]),
434            ]
435        );
436    }
437}