Skip to main content

robit_agent/
agent.rs

1//! Agent — the event-driven loop that orchestrates LLM calls and tool execution.
2
3use async_openai::types::chat::{
4    ChatCompletionMessageToolCall, ChatCompletionMessageToolCalls,
5    ChatCompletionRequestAssistantMessage, ChatCompletionRequestMessage,
6    ChatCompletionRequestSystemMessage, ChatCompletionRequestToolMessage,
7    ChatCompletionRequestUserMessage, ChatCompletionRequestUserMessageContent,
8    ChatCompletionRequestUserMessageContentPart,
9    ChatCompletionRequestMessageContentPartText,
10    ChatCompletionRequestMessageContentPartImage,
11    FunctionCall,
12};
13
14// Import ImageUrl from wherever it is in async-openai 0.41
15use async_openai::types::chat::ImageUrl;
16use futures::StreamExt;
17use robit_ai::config::ContextConfig;
18use robit_ai::LlmClient;
19use std::any::Any;
20use std::collections::HashMap;
21use std::path::PathBuf;
22use std::sync::Arc;
23use tokio::sync::mpsc;
24
25use crate::context::ContextManager;
26use crate::error::{AgentError, Result};
27use crate::event::{new_session_id, AgentEvent, FrontendMessage, MediaAttachment, SessionId};
28use crate::frontend::Frontend;
29use crate::media;
30use crate::prompt::PromptBuilder;
31use crate::skill::SkillRegistry;
32use crate::tool::{ToolCallInfo, ToolContext, ToolRegistry, ToolResult};
33
34// ============================================================================
35// AgentSession
36// ============================================================================
37
38/// A single conversation session with its own message history.
39pub struct AgentSession {
40    pub session_id: SessionId,
41    pub history: Vec<ChatCompletionRequestMessage>,
42    pub working_dir: PathBuf,
43}
44
45impl AgentSession {
46    fn new(session_id: SessionId, working_dir: PathBuf, system_prompt: String) -> Self {
47        let system_msg = ChatCompletionRequestMessage::System(
48            ChatCompletionRequestSystemMessage {
49                content: system_prompt.into(),
50                name: None,
51            }
52            .into(),
53        );
54
55        Self {
56            session_id,
57            history: vec![system_msg],
58            working_dir,
59        }
60    }
61
62    /// Create session with pre-loaded history
63    pub fn with_history(
64        session_id: SessionId,
65        working_dir: PathBuf,
66        system_prompt: String,
67        history: Vec<ChatCompletionRequestMessage>,
68    ) -> Self {
69        // Create system message (new one with latest config)
70        let system_msg = ChatCompletionRequestMessage::System(
71            ChatCompletionRequestSystemMessage {
72                content: system_prompt.into(),
73                name: None,
74            }
75            .into(),
76        );
77
78        // Prepend new system message to history
79        let mut full_history = vec![system_msg];
80        full_history.extend(history);
81
82        Self {
83            session_id,
84            history: full_history,
85            working_dir,
86        }
87    }
88}
89
90// ============================================================================
91// Agent
92// ============================================================================
93
94/// The Agent orchestrates LLM calls and tool execution.
95pub struct Agent {
96    llm_client: Arc<LlmClient>,
97    tools: Arc<ToolRegistry>,
98    skills: Arc<SkillRegistry>,
99    sessions: HashMap<SessionId, AgentSession>,
100    default_session_id: SessionId,
101    context_manager: ContextManager,
102    frontend: Arc<dyn Frontend>,
103    auto_approve: bool,
104    /// Platform-specific extensions passed to ToolContext during tool execution.
105    extensions: HashMap<String, Arc<dyn Any + Send + Sync>>,
106    /// Pending truncation result that needs compression (handled at start of run loop).
107    pending_truncation: Option<(SessionId, crate::context::TruncationResult)>,
108}
109
110impl Agent {
111    /// Create a new Agent with the given dependencies.
112    pub fn new(
113        llm_client: Arc<LlmClient>,
114        tools: Arc<ToolRegistry>,
115        skills: Arc<SkillRegistry>,
116        frontend: Arc<dyn Frontend>,
117        context_config: Option<&ContextConfig>,
118        context_window: Option<u64>,
119        working_dir: PathBuf,
120        auto_approve: bool,
121        extensions: HashMap<String, Arc<dyn Any + Send + Sync>>,
122    ) -> Self {
123        let prompt_builder = PromptBuilder::with_working_dir(Some(&working_dir));
124        let context_manager = ContextManager::new(context_window, context_config);
125
126        // Build system prompt with tools AND skills
127        let tool_refs: Vec<&dyn crate::tool::Tool> = tools.tools();
128        let skill_descs = skills.skill_descriptions();
129        let system_prompt = prompt_builder.build_system_prompt(&tool_refs, &skill_descs, &working_dir);
130
131        // Create default session
132        let session_id = new_session_id();
133        let session = AgentSession::new(session_id.clone(), working_dir, system_prompt);
134
135        let mut sessions = HashMap::new();
136        sessions.insert(session_id.clone(), session);
137
138        Self {
139            llm_client,
140            tools,
141            skills,
142            sessions,
143            default_session_id: session_id,
144            context_manager,
145            frontend,
146            auto_approve,
147            extensions,
148            pending_truncation: None,
149        }
150    }
151
152    /// Create Agent with pre-loaded history (for resuming sessions)
153    pub fn with_history(
154        llm_client: Arc<LlmClient>,
155        tools: Arc<ToolRegistry>,
156        skills: Arc<SkillRegistry>,
157        frontend: Arc<dyn Frontend>,
158        context_config: Option<&ContextConfig>,
159        context_window: Option<u64>,
160        working_dir: PathBuf,
161        auto_approve: bool,
162        extensions: HashMap<String, Arc<dyn Any + Send + Sync>>,
163        session_id: SessionId,
164        history: Vec<ChatCompletionRequestMessage>,
165    ) -> Self {
166        tracing::info!(
167            "Agent::with_history: session_id={}, received {} history messages",
168            session_id,
169            history.len()
170        );
171        for (idx, msg) in history.iter().enumerate() {
172            let role = match msg {
173                ChatCompletionRequestMessage::System(_) => "system",
174                ChatCompletionRequestMessage::User(_) => "user",
175                ChatCompletionRequestMessage::Assistant(_) => "assistant",
176                ChatCompletionRequestMessage::Tool(_) => "tool",
177                ChatCompletionRequestMessage::Developer(_) => "developer",
178                ChatCompletionRequestMessage::Function(_) => "function",
179            };
180            tracing::debug!("  History message {}: role={}", idx, role);
181        }
182
183        let prompt_builder = PromptBuilder::with_working_dir(Some(&working_dir));
184        let context_manager = ContextManager::new(context_window, context_config);
185
186        // Build system prompt with tools AND skills
187        let tool_refs: Vec<&dyn crate::tool::Tool> = tools.tools();
188        let skill_descs = skills.skill_descriptions();
189        let system_prompt = prompt_builder.build_system_prompt(&tool_refs, &skill_descs, &working_dir);
190
191        // Create session with history
192        let mut session = AgentSession::with_history(
193            session_id.clone(),
194            working_dir,
195            system_prompt,
196            history,
197        );
198
199        tracing::info!(
200            "Agent::with_history: after adding system prompt, session history length = {}",
201            session.history.len()
202        );
203        for (idx, msg) in session.history.iter().enumerate() {
204            let role = match msg {
205                ChatCompletionRequestMessage::System(_) => "system",
206                ChatCompletionRequestMessage::User(_) => "user",
207                ChatCompletionRequestMessage::Assistant(_) => "assistant",
208                ChatCompletionRequestMessage::Tool(_) => "tool",
209                ChatCompletionRequestMessage::Developer(_) => "developer",
210                ChatCompletionRequestMessage::Function(_) => "function",
211            };
212            tracing::debug!("  Session history {}: role={}", idx, role);
213        }
214
215        // Apply context truncation before starting
216        let truncation_result = context_manager.maybe_truncate(&mut session.history);
217        if truncation_result.rounds_removed > 0 {
218            tracing::info!(
219                "Agent::with_history: truncated {} rounds ({} messages), needs_compression={}",
220                truncation_result.rounds_removed,
221                truncation_result.messages_removed,
222                truncation_result.needs_compression
223            );
224        }
225        tracing::debug!(
226            "Agent::with_history: after truncation, session history length = {}",
227            session.history.len()
228        );
229
230        let pending_truncation = if truncation_result.needs_compression {
231            Some((session_id.clone(), truncation_result))
232        } else {
233            None
234        };
235
236        let mut sessions = HashMap::new();
237        sessions.insert(session_id.clone(), session);
238
239        Self {
240            llm_client,
241            tools,
242            skills,
243            sessions,
244            default_session_id: session_id,
245            context_manager,
246            frontend,
247            auto_approve,
248            extensions,
249            pending_truncation,
250        }
251    }
252
253    /// Run the agent's main event loop. Takes ownership of the message receiver.
254    /// Returns when the channel is closed or user types /exit.
255    pub async fn run(mut self, mut message_rx: mpsc::Receiver<FrontendMessage>) {
256        tracing::info!("Agent started, session: {}", self.default_session_id);
257
258        // Handle pending compression from with_history initialization
259        if let Some((session_id, truncation_result)) = self.pending_truncation.take() {
260            tracing::info!("=== Starting pending compression processing ===");
261            tracing::info!("Session ID: {}", session_id);
262            tracing::info!("Removed rounds: {}", truncation_result.rounds_removed);
263            tracing::info!("Removed messages: {}", truncation_result.messages_removed);
264            tracing::info!("Insert position: {}", truncation_result.insert_position);
265
266            let removed_tokens = crate::context::estimate_messages_tokens(&truncation_result.removed_messages);
267            tracing::info!("Estimated removed tokens: {}", removed_tokens);
268
269            tracing::info!("Generating summary for {} removed messages...", truncation_result.removed_messages.len());
270            let summary = generate_summary(&self.llm_client, &truncation_result.removed_messages).await;
271            tracing::info!("Generated summary: \"{}\"", summary);
272
273            // Replace the placeholder notice with the actual summary
274            let mut replaced = false;
275            if let Some(session) = self.sessions.get_mut(&session_id) {
276                tracing::info!("Session found, current history length: {}", session.history.len());
277                if let Some(msg) = session.history.get_mut(truncation_result.insert_position) {
278                    let notice = format!("[Earlier conversation summary: {}]", summary);
279                    tracing::info!("Replacing message at position {} with summary...", truncation_result.insert_position);
280                    *msg = ChatCompletionRequestMessage::User(
281                        ChatCompletionRequestUserMessage {
282                            content: notice.into(),
283                            name: Some("system_notice".to_string()),
284                        }
285                    );
286                    replaced = true;
287                    tracing::info!("Compression complete! Summary successfully inserted at position {}", truncation_result.insert_position);
288                } else {
289                    tracing::warn!("Insert position {} not found in session history (length: {})",
290                        truncation_result.insert_position, session.history.len());
291                }
292            } else {
293                tracing::warn!("Session {} not found, cannot insert summary", session_id);
294            }
295
296            if !replaced {
297                tracing::warn!("Summary not inserted, using fallback behavior");
298            }
299            tracing::info!("=== Compression processing finished ===");
300        } else {
301            tracing::info!("No pending compression needed");
302        }
303
304        while let Some(msg) = message_rx.recv().await {
305            match msg {
306                FrontendMessage::UserInput { text, attachments } => {
307                    if text == "/exit" || text == "/quit" {
308                        break;
309                    }
310                    if text == "/clear" {
311                        self.clear_session();
312                        let _ = self
313                            .frontend
314                            .on_event(AgentEvent::TextDelta(
315                                "\n[Conversation history cleared]\n".to_string(),
316                            ))
317                            .await;
318                        let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
319                        continue;
320                    }
321
322                    // Check for skill trigger
323                    if let Some((skill, args)) = self.skills.match_trigger(&text) {
324                        let skill = skill.clone();
325                        self.run_skill_turn(&skill, &args).await;
326                        continue;
327                    }
328
329                    self.run_turn(&text, attachments).await;
330                }
331                FrontendMessage::Cancel => {
332                    tracing::info!("Cancel requested (MVP: no-op)");
333                }
334                FrontendMessage::ConfirmationResponse { .. } => {
335                    // Confirmation is handled via frontend.request_tool_confirmation()
336                    // within run_one_step. This variant is reserved for future async flow.
337                    tracing::warn!("Unexpected ConfirmationResponse outside tool confirmation");
338                }
339            }
340        }
341
342        tracing::info!("Agent stopped");
343    }
344
345    /// Execute a single turn: user input -> LLM call(s) -> tool execution(s) -> response.
346    async fn run_turn(&mut self, user_input: &str, attachments: Vec<MediaAttachment>) {
347        let session_id = self.default_session_id.clone();
348        let max_tool_calls = self.context_manager.max_tool_calls_per_turn;
349
350        // Build user message first (to avoid borrow conflict)
351        let user_message = self.build_user_message(user_input, &attachments).await;
352
353        // Add user message to history
354        if let Some(session) = self.sessions.get_mut(&session_id) {
355            session.history.push(user_message);
356        }
357
358        // Run the agentic loop (may iterate if LLM calls tools)
359        let max_iterations = 20;
360        let mut total_tool_calls = 0usize;
361        for iteration in 0..max_iterations {
362            match self.run_one_step(&session_id).await {
363                Ok(tool_call_count) => {
364                    if tool_call_count == 0 {
365                        let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
366                        return;
367                    }
368                    total_tool_calls += tool_call_count;
369
370                    // Check against per-turn tool call limit
371                    if total_tool_calls >= max_tool_calls {
372                        tracing::warn!(
373                            "Tool call limit reached: {} >= {} (max_tool_calls_per_turn), forcing turn completion",
374                            total_tool_calls,
375                            max_tool_calls
376                        );
377                        let _ = self
378                            .frontend
379                            .on_event(AgentEvent::TextDelta(
380                                format!(
381                                    "\n\n[Tool call limit reached ({} calls). Please summarize progress and continue in the next message.]\n",
382                                    total_tool_calls
383                                ),
384                            ))
385                            .await;
386                        let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
387                        return;
388                    }
389
390                    tracing::debug!(
391                        "Iteration {}: {} tool calls executed (total: {}/{}), continuing loop",
392                        iteration,
393                        tool_call_count,
394                        total_tool_calls,
395                        max_tool_calls
396                    );
397                }
398                Err(e) => {
399                    let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
400                    let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
401                    return;
402                }
403            }
404        }
405
406        // Safety limit
407        let _ = self
408            .frontend
409            .on_event(AgentEvent::Error(AgentError::InternalError(
410                format!("Max iterations reached ({})", max_iterations),
411            )))
412            .await;
413        let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
414    }
415
416    /// Run one step: call LLM, process response, execute tools.
417    /// Returns the number of tool calls executed (0 = turn complete, no tools called).
418    async fn run_one_step(&mut self, session_id: &SessionId) -> Result<usize> {
419        // First get the working_dir before the first mutable borrow
420        let working_dir = {
421            let session = self
422                .sessions
423                .get(session_id)
424                .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
425            session.working_dir.clone()
426        };
427
428        let session = self
429            .sessions
430            .get_mut(session_id)
431            .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
432
433        // Truncate context if needed
434        let truncation_result = self.context_manager.maybe_truncate(&mut session.history);
435
436        // Handle compression: generate actual summary via LLM
437        if truncation_result.needs_compression {
438            let summary = generate_summary(&self.llm_client, &truncation_result.removed_messages).await;
439
440            // Replace the placeholder notice with the actual summary
441            if let Some(msg) = session.history.get_mut(truncation_result.insert_position) {
442                let notice = format!("[Earlier conversation summary: {}]", summary);
443                *msg = ChatCompletionRequestMessage::User(
444                    ChatCompletionRequestUserMessage {
445                        content: notice.into(),
446                        name: Some("system_notice".to_string()),
447                    }
448                );
449            }
450
451            tracing::info!(
452                "Compression completed: removed {} tokens, summary inserted",
453                crate::context::estimate_messages_tokens(&truncation_result.removed_messages),
454            );
455        }
456
457        // Build tool schemas
458        let tool_schemas = self.tools.tool_schemas();
459        let tools_param = if tool_schemas.is_empty() {
460            None
461        } else {
462            Some(tool_schemas)
463        };
464
465        // Log estimated token usage before call
466        let estimated_prompt = crate::context::estimate_messages_tokens_with_margin(
467            &session.history,
468            self.context_manager.token_safety_margin,
469        );
470        tracing::info!(
471            "LLM call: ~{} prompt tokens (with {:.1}x margin), {} messages, threshold={} tokens",
472            estimated_prompt,
473            self.context_manager.token_safety_margin,
474            session.history.len(),
475            self.context_manager.truncation_threshold(),
476        );
477
478        // Call LLM (streaming)
479        let mut stream = self
480            .llm_client
481            .chat_stream(session.history.clone(), tools_param)
482            .await?;
483
484        // Collect streaming response
485        let mut full_text = String::new();
486        let mut tool_call_chunks: HashMap<usize, ToolCallAccumulator> = HashMap::new();
487        let mut api_usage: Option<async_openai::types::chat::CompletionUsage> = None;
488
489        while let Some(chunk_result) = stream.next().await {
490            let chunk = chunk_result.map_err(|e| AgentError::LlmError(e.into()))?;
491
492            // Capture usage info if present in this chunk (some providers include it in final chunk)
493            if let Some(ref usage) = chunk.usage {
494                api_usage = Some(usage.clone());
495            }
496
497            if let Some(choice) = chunk.choices.first() {
498                // Text content
499                if let Some(content) = &choice.delta.content {
500                    full_text.push_str(content);
501                    let _ = self
502                        .frontend
503                        .on_event(AgentEvent::TextDelta(content.clone()))
504                        .await;
505                }
506
507                // Tool call deltas
508                if let Some(tool_calls) = &choice.delta.tool_calls {
509                    for tc in tool_calls {
510                        tracing::debug!(
511                            "Received tool call chunk: index={}, id={:?}, function={:?}",
512                            tc.index,
513                            tc.id,
514                            tc.function
515                        );
516
517                        let acc = tool_call_chunks
518                            .entry(tc.index as usize)
519                            .or_insert_with(ToolCallAccumulator::new);
520
521                        if let Some(id) = &tc.id {
522                            // 只有当id非空时才更新
523                            if !id.is_empty() {
524                                tracing::debug!("Updating tool id: '{}'", id);
525                                acc.id = Some(id.clone());
526                            }
527                        }
528                        if let Some(function) = &tc.function {
529                            if let Some(name) = &function.name {
530                                // 只有当name非空时才更新
531                                if !name.is_empty() {
532                                    tracing::debug!("Tool name chunk: '{}'", name);
533                                    acc.name = Some(name.clone());
534                                }
535                            }
536                            if let Some(args) = &function.arguments {
537                                tracing::debug!("Tool args chunk: '{}'", args);
538                                acc.arguments.push_str(args);
539                            }
540                        }
541
542                        tracing::debug!("Accumulator state after chunk: {:?}", acc);
543                    }
544                }
545            }
546        }
547
548        // Assemble complete tool calls from chunks
549        let assembled_tool_calls: Vec<ChatCompletionMessageToolCall> = {
550            let mut indices: Vec<usize> = tool_call_chunks.keys().cloned().collect();
551            indices.sort();
552            indices
553                .into_iter()
554                .filter_map(|idx| tool_call_chunks.remove(&idx)?.into_tool_call())
555                .collect()
556        };
557
558        // Log token usage summary
559        let estimated_response = crate::context::estimate_tokens(&full_text);
560        if let Some(ref usage) = api_usage {
561            tracing::info!(
562                "LLM response: API usage = {} prompt + {} completion = {} total tokens. Estimated: ~{} prompt + ~{} response = ~{} total",
563                usage.prompt_tokens,
564                usage.completion_tokens,
565                usage.total_tokens,
566                estimated_prompt,
567                estimated_response,
568                estimated_prompt + estimated_response,
569            );
570        } else {
571            tracing::info!(
572                "LLM response: {} chars, ~{} estimated tokens ({} tool calls). API usage not available from streaming.",
573                full_text.len(),
574                estimated_response,
575                assembled_tool_calls.len(),
576            );
577        }
578
579        tracing::debug!("Assembled {} tool call(s)", assembled_tool_calls.len());
580        for (i, tc) in assembled_tool_calls.iter().enumerate() {
581            tracing::debug!(
582                "Tool call [{}]: id='{}', name='{}', arguments='{}'",
583                i,
584                tc.id,
585                tc.function.name,
586                tc.function.arguments
587            );
588        }
589
590        // Add assistant message to history
591        let content = if full_text.is_empty() {
592            None
593        } else {
594            Some(full_text.clone().into())
595        };
596        let tool_calls = if assembled_tool_calls.is_empty() {
597            None
598        } else {
599            Some(
600                assembled_tool_calls
601                    .clone()
602                    .into_iter()
603                    .map(ChatCompletionMessageToolCalls::Function)
604                    .collect(),
605            )
606        };
607
608        // Ensure we don't add an invalid assistant message to history
609        if content.is_some() || tool_calls.is_some() {
610            let assistant_msg = ChatCompletionRequestMessage::Assistant(
611                ChatCompletionRequestAssistantMessage {
612                    content,
613                    name: None,
614                    tool_calls,
615                    refusal: None,
616                    audio: None,
617                    #[allow(deprecated)]
618                    function_call: None,
619                }
620                .into(),
621            );
622
623            session.history.push(assistant_msg);
624        } else {
625            tracing::warn!("Not adding empty assistant message to history (no content and no tool calls)");
626        }
627
628        // If no tool calls, turn is complete
629        if assembled_tool_calls.is_empty() {
630            return Ok(0);
631        }
632
633        // Execute each tool call
634        for tc in &assembled_tool_calls {
635            tracing::info!(
636                "About to execute tool: id='{}', name='{}'",
637                tc.id,
638                tc.function.name
639            );
640
641            let tc_info = ToolCallInfo {
642                id: tc.id.clone(),
643                name: tc.function.name.clone(),
644                arguments: tc.function.arguments.clone(),
645            };
646
647            // Notify frontend
648            let _ = self
649                .frontend
650                .on_event(AgentEvent::ToolCallRequested {
651                    tool_call_id: tc_info.id.clone(),
652                    name: tc_info.name.clone(),
653                    arguments: tc_info.arguments.clone(),
654                })
655                .await;
656
657            // Check confirmation
658            let approved = if self.tools.requires_confirmation(&tc.function.name) && !self.auto_approve {
659                self.frontend.request_tool_confirmation(&tc_info).await?
660            } else {
661                true
662            };
663
664            // Execute or reject
665            let result = if approved {
666                let args: serde_json::Value = serde_json::from_str(&tc.function.arguments)
667                    .unwrap_or(serde_json::Value::Null);
668
669                let ctx = ToolContext {
670                    working_dir: working_dir.clone(),
671                    session_id: session_id.clone(),
672                    frontend: self.frontend.clone(),
673                    extensions: self.extensions.clone(),
674                };
675
676                self.tools.execute(&tc.function.name, args, &ctx).await
677            } else {
678                ToolResult::error("User rejected this tool call")
679            };
680
681            // Truncate output
682            let truncated_result = ToolResult {
683                content: self.context_manager.truncate_tool_output(&result.content),
684                is_error: result.is_error,
685            };
686
687            // Notify frontend of result
688            let _ = self
689                .frontend
690                .on_event(AgentEvent::ToolCallResult {
691                    tool_call_id: tc.id.clone(),
692                    result: truncated_result.clone(),
693                })
694                .await;
695
696            // Add tool result to history
697            let tool_msg = ChatCompletionRequestMessage::Tool(
698                ChatCompletionRequestToolMessage {
699                    content: truncated_result.content.into(),
700                    tool_call_id: tc.id.clone(),
701                }
702                .into(),
703            );
704
705            let session = self
706                .sessions
707                .get_mut(session_id)
708                .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
709            session.history.push(tool_msg);
710        }
711
712        Ok(assembled_tool_calls.len())
713    }
714
715    /// Clear the current session's history (keep system prompt).
716    fn clear_session(&mut self) {
717        if let Some(session) = self.sessions.get_mut(&self.default_session_id) {
718            session.history.truncate(1);
719        }
720    }
721
722    /// Build a user message, potentially with images if model supports them.
723    async fn build_user_message(
724        &self,
725        text: &str,
726        attachments: &[MediaAttachment],
727    ) -> ChatCompletionRequestMessage {
728        // If model supports images and we have image attachments, build multimodal message
729        if self.llm_client.supports_images()
730            && !attachments.is_empty()
731            && attachments.iter().any(|a| a.is_image())
732        {
733            self.build_multimodal_message(text, attachments)
734                .await
735        } else {
736            // Fallback: add attachment descriptions to text
737            let mut full_text = text.to_string();
738            for attachment in attachments {
739                full_text = format!("{}\n{}", full_text, attachment.describe());
740            }
741            ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
742                content: full_text.into(),
743                name: None,
744            })
745        }
746    }
747
748    /// Build a multimodal message with text + images.
749    async fn build_multimodal_message(
750        &self,
751        text: &str,
752        attachments: &[MediaAttachment],
753    ) -> ChatCompletionRequestMessage {
754        let mut parts = vec![ChatCompletionRequestUserMessageContentPart::Text(
755            ChatCompletionRequestMessageContentPartText {
756                text: text.to_string(),
757            },
758        )];
759
760        // Add images
761        for attachment in attachments {
762            if attachment.is_image() {
763                // Download and encode as base64
764                match media::download_and_encode_base64(
765                    &attachment.url,
766                    &attachment.content_type,
767                )
768                .await
769                {
770                    Ok(base64_url) => {
771                        parts.push(ChatCompletionRequestUserMessageContentPart::ImageUrl(
772                            ChatCompletionRequestMessageContentPartImage {
773                                image_url: ImageUrl {
774                                    url: base64_url,
775                                    detail: None,
776                                },
777                            },
778                        ));
779                    }
780                    Err(e) => {
781                        tracing::warn!("Failed to encode image: {}", e);
782                        // Fallback to description
783                        let desc = attachment.describe();
784                        let current_text = match &mut parts[0] {
785                            ChatCompletionRequestUserMessageContentPart::Text(t) => &mut t.text,
786                            _ => unreachable!(),
787                        };
788                        *current_text = format!("{}\n{}", current_text, desc);
789                    }
790                }
791            } else {
792                // Non-image: add description
793                let desc = attachment.describe();
794                let current_text = match &mut parts[0] {
795                    ChatCompletionRequestUserMessageContentPart::Text(t) => &mut t.text,
796                    _ => unreachable!(),
797                };
798                *current_text = format!("{}\n{}", current_text, desc);
799            }
800        }
801
802        ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
803            content: ChatCompletionRequestUserMessageContent::Array(parts),
804            name: None,
805        })
806    }
807
808    /// Execute a skill-triggered turn: inject skill content, then run the agent loop.
809    ///
810    /// The skill's full content is injected as a temporary system message and removed
811    /// after the turn completes, so it doesn't occupy context in future turns.
812    async fn run_skill_turn(&mut self, skill: &crate::skill::Skill, args: &str) {
813        // Notify frontend
814        let _ = self
815            .frontend
816            .on_event(AgentEvent::SkillTriggered {
817                name: skill.frontmatter.name.clone(),
818                description: skill.frontmatter.description.clone(),
819            })
820            .await;
821
822        let session_id = self.default_session_id.clone();
823
824        // Inject skill content as a system message
825        let skill_message = format!(
826            "## Skill: {}\n\n{}\n\n{}",
827            skill.frontmatter.name,
828            skill.frontmatter.description,
829            skill.content
830        );
831
832        let skill_msg = ChatCompletionRequestMessage::System(
833            ChatCompletionRequestSystemMessage {
834                content: skill_message.into(),
835                name: Some(skill.frontmatter.name.clone()),
836            }
837            .into(),
838        );
839
840        if let Some(session) = self.sessions.get_mut(&session_id) {
841            session.history.push(skill_msg);
842        }
843
844        // Add user message (args or default)
845        let user_content = if args.is_empty() {
846            "(User triggered skill, no additional arguments)".to_string()
847        } else {
848            args.to_string()
849        };
850
851        if let Some(session) = self.sessions.get_mut(&session_id) {
852            session.history.push(ChatCompletionRequestMessage::User(
853                ChatCompletionRequestUserMessage {
854                    content: user_content.into(),
855                    name: None,
856                }
857                .into(),
858            ));
859        }
860
861        // Run the agentic loop
862        let max_iterations = 20;
863        let mut completed = false;
864        for iteration in 0..max_iterations {
865            match self.run_one_step(&session_id).await {
866                Ok(tool_call_count) => {
867                    if tool_call_count == 0 {
868                        completed = true;
869                        break;
870                    }
871                    tracing::debug!(
872                        "Skill iteration {}: tool calls executed",
873                        iteration
874                    );
875                }
876                Err(e) => {
877                    let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
878                    break;
879                }
880            }
881        }
882
883        if !completed {
884            let _ = self
885                .frontend
886                .on_event(AgentEvent::Error(AgentError::InternalError(
887                    format!("Max iterations reached ({})", max_iterations),
888                )))
889                .await;
890        }
891
892        let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
893
894        // Remove the injected skill system message to avoid polluting future turns
895        if let Some(session) = self.sessions.get_mut(&session_id) {
896            let skill_name = skill.frontmatter.name.clone();
897            session.history.retain(|msg| {
898                !matches!(
899                    msg,
900                    ChatCompletionRequestMessage::System(s)
901                        if s.name.as_deref() == Some(&skill_name)
902                )
903            });
904        }
905    }
906
907    }
908
909// ============================================================================
910// Summary generation (free function to avoid borrow conflicts)
911// ============================================================================
912
913/// Generate a summary of removed conversation messages using the LLM.
914/// Uses a non-streaming call to produce a 1-2 sentence summary.
915/// Falls back to a static message on failure.
916async fn generate_summary(
917    llm_client: &LlmClient,
918    removed_messages: &[ChatCompletionRequestMessage],
919) -> String {
920    tracing::debug!("Generating summary: removed_messages count = {}", removed_messages.len());
921    let transcript = crate::context::format_removed_messages_as_transcript(removed_messages);
922    tracing::debug!("Formatted transcript length: {} characters", transcript.len());
923    tracing::trace!("Transcript content:\n{}", transcript);
924
925    let system_prompt = "Summarize the following conversation transcript in 1-2 concise sentences. Focus on: what the user asked for, what actions were taken, and the outcomes. Be brief and factual.";
926
927    let messages = vec![
928        ChatCompletionRequestMessage::System(
929            ChatCompletionRequestSystemMessage {
930                content: system_prompt.into(),
931                name: None,
932            }
933        ),
934        ChatCompletionRequestMessage::User(
935            ChatCompletionRequestUserMessage {
936                content: format!("Conversation transcript:\n\n{}", transcript).into(),
937                name: None,
938            }
939        ),
940    ];
941
942    tracing::info!("Calling LLM to generate summary...");
943    match llm_client.chat(messages, None).await {
944        Ok(response) => {
945            tracing::info!("LLM responded successfully for summary generation");
946            tracing::debug!("Number of choices in response: {}", response.choices.len());
947            if let Some(choice) = response.choices.first() {
948                tracing::debug!("Choice index: 0, has content: {}", choice.message.content.is_some());
949                if let Some(content) = &choice.message.content {
950                    let summary = content.trim().to_string();
951                    if !summary.is_empty() {
952                        tracing::info!("Successfully generated summary (length: {})", summary.len());
953                        return summary;
954                    }
955                }
956            }
957            tracing::warn!("Summary generation returned empty response, using fallback");
958            "Conversation history compressed.".to_string()
959        }
960        Err(e) => {
961            tracing::error!("Summary generation failed with error: {}, using fallback", e);
962            "Conversation history compressed.".to_string()
963        }
964    }
965}
966
967// ============================================================================
968// Helper types
969// ============================================================================
970
971/// Accumulates streaming tool call chunks.
972#[derive(Debug)]
973struct ToolCallAccumulator {
974    id: Option<String>,
975    name: Option<String>,
976    arguments: String,
977}
978
979impl ToolCallAccumulator {
980    fn new() -> Self {
981        Self {
982            id: None,
983            name: None,
984            arguments: String::new(),
985        }
986    }
987
988    /// Convert accumulated chunks into a complete tool call.
989    fn into_tool_call(self) -> Option<ChatCompletionMessageToolCall> {
990        tracing::debug!("Converting accumulator to tool call: {:?}", self);
991
992        let id = self.id?;
993        let name = self.name?;
994
995        tracing::debug!("Tool call assembled: id='{}', name='{}', args='{}'", id, name, self.arguments);
996
997        Some(ChatCompletionMessageToolCall {
998            id,
999            function: FunctionCall {
1000                name,
1001                arguments: self.arguments,
1002            },
1003        })
1004    }
1005}