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