Skip to main content

vtcode_core/core/agent/
conversation.rs

1//! Helpers for composing agent conversations and bridging provider-specific message formats.
2
3use crate::core::agent::task::{ContextItem, Task};
4use crate::llm::provider::{ContentPart, Message, MessageContent, MessageRole, ToolCall};
5use crate::llm::providers::gemini::wire::{
6    Content, FunctionCall, FunctionResponse, InlineData, Part,
7};
8use serde_json::{Value, json};
9use std::collections::HashMap;
10use std::fmt::Write;
11
12const GEMINI_PRESERVED_PARTS_PREFIX: &str = "__vtcode_gemini_parts__:";
13
14/// Build the initial conversation payload (without the system instruction message).
15pub fn build_conversation(task: &Task, contexts: &[ContextItem]) -> Vec<Content> {
16    let mut conversation = Vec::with_capacity(3);
17    let mut task_content = String::with_capacity(task.title.len() + task.description.len() + 20);
18    let _ = write!(
19        task_content,
20        "Task: {}\nDescription: {}",
21        task.title, task.description
22    );
23    conversation.push(Content::user_text(task_content));
24
25    if let Some(instructions) = task.instructions.as_ref() {
26        conversation.push(Content::user_text(instructions.clone()));
27    }
28
29    if !contexts.is_empty() {
30        let mut context_content = String::from("Relevant Context:");
31        for ctx in contexts {
32            let _ = write!(context_content, "\nContext [{}]: {}", ctx.id, ctx.content);
33        }
34        conversation.push(Content::user_text(context_content));
35    }
36
37    conversation
38}
39
40/// Convert Gemini `Content` structures into universal provider messages.
41pub fn messages_from_conversation(conversation: &[Content]) -> Vec<Message> {
42    let mut messages = Vec::with_capacity(conversation.len());
43    for content in conversation {
44        let part_count = content.parts.len();
45        let mut content_parts = Vec::with_capacity(part_count);
46        let mut tool_calls = Vec::new();
47        let mut tool_responses = Vec::new();
48
49        for part in &content.parts {
50            match part {
51                Part::Text {
52                    text: part_text, ..
53                } => {
54                    if let Some(ContentPart::Text { text }) = content_parts.last_mut() {
55                        if !text.is_empty() {
56                            text.push('\n');
57                        }
58                        text.push_str(part_text);
59                    } else if !part_text.is_empty() {
60                        content_parts.push(ContentPart::text(part_text.clone()));
61                    }
62                }
63                Part::InlineData { inline_data } => {
64                    content_parts.push(ContentPart::image(
65                        inline_data.data.clone(),
66                        inline_data.mime_type.clone(),
67                    ));
68                }
69                Part::FunctionCall {
70                    function_call,
71                    thought_signature,
72                } => {
73                    let mut tool_call = ToolCall::function(
74                        function_call.id.clone().unwrap_or_default(),
75                        function_call.name.clone(),
76                        function_call.args.to_string(),
77                    );
78                    tool_call.thought_signature = thought_signature.clone();
79                    tool_calls.push(tool_call);
80                }
81                Part::FunctionResponse {
82                    function_response, ..
83                } => {
84                    let id = function_response
85                        .id
86                        .clone()
87                        .unwrap_or_else(|| "unknown".to_string());
88                    let response_str = function_response.response.to_string();
89                    tool_responses.push(Message::tool_response(id, response_str));
90                }
91                Part::ToolCall { .. }
92                | Part::ToolResponse { .. }
93                | Part::ExecutableCode { .. }
94                | Part::CodeExecutionResult { .. } => {}
95                Part::CacheControl { .. } => {}
96            }
97        }
98
99        if !tool_responses.is_empty() {
100            messages.extend(tool_responses);
101            if !content_parts.is_empty() {
102                messages.push(Message::user_with_parts(content_parts));
103            }
104            continue;
105        }
106
107        let mut message = match content.role.as_str() {
108            "model" => {
109                if content_parts.is_empty() {
110                    Message::assistant(String::new())
111                } else {
112                    Message::assistant_with_parts(content_parts)
113                }
114            }
115            _ => {
116                if content_parts.is_empty() {
117                    Message::user(String::new())
118                } else {
119                    Message::user_with_parts(content_parts)
120                }
121            }
122        };
123
124        if !tool_calls.is_empty() {
125            message.tool_calls = Some(tool_calls);
126        }
127
128        if let Some(raw_parts_detail) = preserved_parts_detail(&content.parts) {
129            message = message.with_reasoning_details(Some(vec![json!(raw_parts_detail)]));
130        }
131
132        messages.push(message);
133    }
134
135    messages
136}
137
138/// Convert Gemini `Content` structures into universal provider messages.
139///
140/// System instructions travel separately via `LLMRequest.system_prompt` on the active request.
141pub fn build_messages_from_conversation(conversation: &[Content]) -> Vec<Message> {
142    messages_from_conversation(conversation)
143}
144
145fn parts_from_message_content(content: &MessageContent) -> Vec<Part> {
146    match content {
147        MessageContent::Text(text) => {
148            if text.is_empty() {
149                Vec::new()
150            } else {
151                vec![Part::Text {
152                    text: text.clone(),
153                    thought_signature: None,
154                }]
155            }
156        }
157        MessageContent::Parts(parts) => {
158            let mut converted = Vec::with_capacity(parts.len());
159            for part in parts {
160                match part {
161                    ContentPart::Text { text } => {
162                        if !text.is_empty() {
163                            converted.push(Part::Text {
164                                text: text.clone(),
165                                thought_signature: None,
166                            });
167                        }
168                    }
169                    ContentPart::Image {
170                        data, mime_type, ..
171                    } => {
172                        converted.push(Part::InlineData {
173                            inline_data: InlineData {
174                                mime_type: mime_type.clone(),
175                                data: data.clone(),
176                            },
177                        });
178                    }
179                    ContentPart::File {
180                        filename,
181                        file_id,
182                        file_url,
183                        ..
184                    } => {
185                        let fallback = filename
186                            .clone()
187                            .or_else(|| file_id.clone())
188                            .or_else(|| file_url.clone())
189                            .unwrap_or_else(|| "attached file".to_string());
190                        converted.push(Part::Text {
191                            text: format!("[File input not directly supported: {fallback}]"),
192                            thought_signature: None,
193                        });
194                    }
195                }
196            }
197            converted
198        }
199    }
200}
201
202fn tool_call_arguments(arguments: &str) -> Value {
203    serde_json::from_str(arguments).unwrap_or_else(|_| Value::String(arguments.to_string()))
204}
205
206fn tool_response_value(content: &MessageContent) -> Value {
207    let text = content.as_text();
208    serde_json::from_str(text.as_ref()).unwrap_or_else(|_| json!({ "result": text.as_ref() }))
209}
210
211/// Rebuild Gemini-style conversation content from archived provider messages.
212///
213/// System messages are skipped because exec regenerates the current system prompt.
214pub fn conversation_from_messages(messages: &[Message]) -> Vec<Content> {
215    let mut conversation = Vec::with_capacity(messages.len());
216    let mut tool_names_by_call_id: HashMap<String, String> = HashMap::with_capacity(messages.len());
217
218    for message in messages {
219        match message.role {
220            MessageRole::System => {}
221            MessageRole::User => {
222                let parts = parts_from_message_content(&message.content);
223                if !parts.is_empty() {
224                    conversation.push(Content {
225                        role: "user".to_string(),
226                        parts,
227                    });
228                }
229            }
230            MessageRole::Assistant => {
231                let parts = preserved_parts_from_message(message).unwrap_or_else(|| {
232                    let mut rebuilt_parts = parts_from_message_content(&message.content);
233                    if let Some(tool_calls) = &message.tool_calls {
234                        for tool_call in tool_calls {
235                            let Some(function) = &tool_call.function else {
236                                continue;
237                            };
238
239                            let id = (!tool_call.id.is_empty()).then(|| tool_call.id.clone());
240                            if let Some(call_id) = id.as_ref() {
241                                tool_names_by_call_id
242                                    .insert(call_id.clone(), function.name.clone());
243                            }
244
245                            rebuilt_parts.push(Part::FunctionCall {
246                                function_call: FunctionCall {
247                                    name: function.name.clone(),
248                                    args: tool_call_arguments(&function.arguments),
249                                    id,
250                                },
251                                thought_signature: tool_call.thought_signature.clone(),
252                            });
253                        }
254                    }
255                    rebuilt_parts
256                });
257
258                for part in &parts {
259                    if let Part::FunctionCall { function_call, .. } = part
260                        && let Some(call_id) = function_call.id.as_ref()
261                    {
262                        tool_names_by_call_id.insert(call_id.clone(), function_call.name.clone());
263                    }
264                }
265
266                if !parts.is_empty() {
267                    conversation.push(Content {
268                        role: "model".to_string(),
269                        parts,
270                    });
271                }
272            }
273            MessageRole::Tool => {
274                let Some(call_id) = message
275                    .tool_call_id
276                    .as_ref()
277                    .filter(|value| !value.is_empty())
278                    .cloned()
279                else {
280                    let parts = parts_from_message_content(&message.content);
281                    if !parts.is_empty() {
282                        conversation.push(Content {
283                            role: "user".to_string(),
284                            parts,
285                        });
286                    }
287                    continue;
288                };
289
290                let tool_name = message
291                    .origin_tool
292                    .clone()
293                    .or_else(|| tool_names_by_call_id.get(&call_id).cloned())
294                    .unwrap_or_else(|| "tool".to_string());
295
296                conversation.push(Content {
297                    role: "function".to_string(),
298                    parts: vec![Part::FunctionResponse {
299                        function_response: FunctionResponse {
300                            name: tool_name,
301                            response: tool_response_value(&message.content),
302                            id: Some(call_id),
303                        },
304                        thought_signature: None,
305                    }],
306                });
307            }
308        }
309    }
310
311    conversation
312}
313
314fn preserved_parts_from_message(message: &Message) -> Option<Vec<Part>> {
315    let details = message.reasoning_details.as_ref()?;
316    for detail in details {
317        let Some(text) = detail.as_str() else {
318            continue;
319        };
320        let Some(payload) = text.strip_prefix(GEMINI_PRESERVED_PARTS_PREFIX) else {
321            continue;
322        };
323        if let Ok(parts) = serde_json::from_str::<Vec<Part>>(payload) {
324            return Some(parts);
325        }
326    }
327    None
328}
329
330fn preserved_parts_detail(parts: &[Part]) -> Option<String> {
331    let should_preserve = parts.iter().any(|part| {
332        part.thought_signature().is_some()
333            || matches!(
334                part,
335                Part::ToolCall { .. }
336                    | Part::ToolResponse { .. }
337                    | Part::ExecutableCode { .. }
338                    | Part::CodeExecutionResult { .. }
339                    | Part::FunctionResponse { .. }
340                    | Part::InlineData { .. }
341            )
342    });
343    if !should_preserve {
344        return None;
345    }
346
347    serde_json::to_string(parts)
348        .ok()
349        .map(|serialized| format!("{GEMINI_PRESERVED_PARTS_PREFIX}{serialized}"))
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::llm::provider::{FunctionCall, ToolCall};
356
357    fn sample_task() -> Task {
358        Task {
359            id: "task-1".to_owned(),
360            title: "Example".to_owned(),
361            description: "Do something".to_owned(),
362            instructions: Some("Follow steps".to_owned()),
363        }
364    }
365
366    #[test]
367    fn conversation_builds_expected_steps() {
368        let task = sample_task();
369        let contexts = vec![ContextItem {
370            id: "ctx1".into(),
371            content: "Data".into(),
372        }];
373        let conversation = build_conversation(&task, &contexts);
374        assert_eq!(conversation.len(), 3);
375    }
376
377    #[test]
378    fn messages_mirror_conversation_without_system_prompt() {
379        let task = sample_task();
380        let conversation = build_conversation(&task, &[]);
381        let messages = build_messages_from_conversation(&conversation);
382        assert_eq!(messages.len(), conversation.len());
383        assert!(
384            messages
385                .iter()
386                .all(|message| message.role != MessageRole::System)
387        );
388    }
389
390    #[test]
391    fn archived_messages_rebuild_function_history() {
392        let history = vec![
393            Message::system("Base".to_string()),
394            Message::user("Inspect src/main.rs".to_string()),
395            Message::assistant_with_tools(
396                "Running read_file".to_string(),
397                vec![ToolCall {
398                    id: "call-1".to_string(),
399                    call_type: "function".to_string(),
400                    function: Some(FunctionCall {
401                        namespace: None,
402                        name: "read_file".to_string(),
403                        arguments: "{\"path\":\"src/main.rs\"}".to_string(),
404                    }),
405                    text: None,
406                    thought_signature: None,
407                }],
408            ),
409            Message::tool_response(
410                "call-1".to_string(),
411                "{\"content\":\"fn main() {}\"}".to_string(),
412            ),
413            Message::assistant("Done".to_string()),
414        ];
415
416        let conversation = conversation_from_messages(&history);
417        let rebuilt = build_messages_from_conversation(&conversation);
418
419        assert_eq!(rebuilt[0].role, MessageRole::User);
420        assert_eq!(rebuilt[0].content.as_text().as_ref(), "Inspect src/main.rs");
421        assert_eq!(rebuilt[1].role, MessageRole::Assistant);
422        assert_eq!(
423            rebuilt[1]
424                .tool_calls
425                .as_ref()
426                .and_then(|calls| calls.first())
427                .and_then(|call| call.function.as_ref())
428                .map(|function| function.name.as_str()),
429            Some("read_file")
430        );
431        assert_eq!(rebuilt[2].role, MessageRole::Tool);
432        assert_eq!(rebuilt[2].tool_call_id.as_deref(), Some("call-1"));
433        assert_eq!(rebuilt[3].role, MessageRole::Assistant);
434        assert_eq!(rebuilt[3].content.as_text().as_ref(), "Done");
435    }
436}