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
293        // Build user message first (to avoid borrow conflict)
294        let user_message = self.build_user_message(user_input, &attachments).await;
295
296        // Add user message to history
297        if let Some(session) = self.sessions.get_mut(&session_id) {
298            session.history.push(user_message);
299        }
300
301        // Run the agentic loop (may iterate if LLM calls tools)
302        let max_iterations = 20;
303        for iteration in 0..max_iterations {
304            match self.run_one_step(&session_id).await {
305                Ok(has_tool_calls) => {
306                    if !has_tool_calls {
307                        let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
308                        return;
309                    }
310                    tracing::debug!(
311                        "Iteration {}: tool calls executed, continuing loop",
312                        iteration
313                    );
314                }
315                Err(e) => {
316                    let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
317                    let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
318                    return;
319                }
320            }
321        }
322
323        // Safety limit
324        let _ = self
325            .frontend
326            .on_event(AgentEvent::Error(AgentError::InternalError(
327                format!("Max iterations reached ({})", max_iterations),
328            )))
329            .await;
330        let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
331    }
332
333    /// Run one step: call LLM, process response, execute tools.
334    /// Returns Ok(true) if tool calls were made (loop should continue).
335    /// Returns Ok(false) if the LLM responded with text only (turn complete).
336    async fn run_one_step(&mut self, session_id: &SessionId) -> Result<bool> {
337        let session = self
338            .sessions
339            .get_mut(session_id)
340            .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
341
342        // Truncate context if needed
343        let truncation_result = self.context_manager.maybe_truncate(&mut session.history);
344
345        // Handle async compression if needed
346        if truncation_result.needs_compression {
347            // For now, replace placeholder with a notice
348            // In production, spawn async task to call LLM and replace with summary
349            if let Some(msg) = session.history.get_mut(truncation_result.insert_position) {
350                let notice = format!(
351                    "[Omitted {} rounds, {} messages. Context compressed to save space]",
352                    truncation_result.rounds_removed,
353                    truncation_result.messages_removed
354                );
355                *msg = ChatCompletionRequestMessage::User(
356                    ChatCompletionRequestUserMessage {
357                        content: notice.into(),
358                        name: Some("system_notice".to_string()),
359                    }
360                    .into(),
361                );
362            }
363
364            tracing::info!(
365                "Compression triggered: removed {} tokens (threshold: {})",
366                crate::context::estimate_messages_tokens(&truncation_result.removed_messages),
367                self.context_manager.compression_token_threshold
368            );
369        }
370
371        // Build tool schemas
372        let tool_schemas = self.tools.tool_schemas();
373        let tools_param = if tool_schemas.is_empty() {
374            None
375        } else {
376            Some(tool_schemas)
377        };
378
379        // Call LLM (streaming)
380        let mut stream = self
381            .llm_client
382            .chat_stream(session.history.clone(), tools_param)
383            .await?;
384
385        // Collect streaming response
386        let mut full_text = String::new();
387        let mut tool_call_chunks: HashMap<usize, ToolCallAccumulator> = HashMap::new();
388
389        while let Some(chunk_result) = stream.next().await {
390            let chunk = chunk_result.map_err(|e| AgentError::LlmError(e.into()))?;
391
392            if let Some(choice) = chunk.choices.first() {
393                // Text content
394                if let Some(content) = &choice.delta.content {
395                    full_text.push_str(content);
396                    let _ = self
397                        .frontend
398                        .on_event(AgentEvent::TextDelta(content.clone()))
399                        .await;
400                }
401
402                // Tool call deltas
403                if let Some(tool_calls) = &choice.delta.tool_calls {
404                    for tc in tool_calls {
405                        tracing::debug!(
406                            "Received tool call chunk: index={}, id={:?}, function={:?}",
407                            tc.index,
408                            tc.id,
409                            tc.function
410                        );
411
412                        let acc = tool_call_chunks
413                            .entry(tc.index as usize)
414                            .or_insert_with(ToolCallAccumulator::new);
415
416                        if let Some(id) = &tc.id {
417                            // 只有当id非空时才更新
418                            if !id.is_empty() {
419                                tracing::debug!("Updating tool id: '{}'", id);
420                                acc.id = Some(id.clone());
421                            }
422                        }
423                        if let Some(function) = &tc.function {
424                            if let Some(name) = &function.name {
425                                // 只有当name非空时才更新
426                                if !name.is_empty() {
427                                    tracing::debug!("Tool name chunk: '{}'", name);
428                                    acc.name = Some(name.clone());
429                                }
430                            }
431                            if let Some(args) = &function.arguments {
432                                tracing::debug!("Tool args chunk: '{}'", args);
433                                acc.arguments.push_str(args);
434                            }
435                        }
436
437                        tracing::debug!("Accumulator state after chunk: {:?}", acc);
438                    }
439                }
440            }
441        }
442
443        // Assemble complete tool calls from chunks
444        let assembled_tool_calls: Vec<ChatCompletionMessageToolCall> = {
445            let mut indices: Vec<usize> = tool_call_chunks.keys().cloned().collect();
446            indices.sort();
447            indices
448                .into_iter()
449                .filter_map(|idx| tool_call_chunks.remove(&idx)?.into_tool_call())
450                .collect()
451        };
452
453        tracing::debug!("Assembled {} tool call(s)", assembled_tool_calls.len());
454        for (i, tc) in assembled_tool_calls.iter().enumerate() {
455            tracing::debug!(
456                "Tool call [{}]: id='{}', name='{}', arguments='{}'",
457                i,
458                tc.id,
459                tc.function.name,
460                tc.function.arguments
461            );
462        }
463
464        // Add assistant message to history
465        let assistant_msg = ChatCompletionRequestMessage::Assistant(
466            ChatCompletionRequestAssistantMessage {
467                content: if full_text.is_empty() {
468                    None
469                } else {
470                    Some(full_text.clone().into())
471                },
472                name: None,
473                tool_calls: if assembled_tool_calls.is_empty() {
474                    None
475                } else {
476                    Some(
477                        assembled_tool_calls
478                            .clone()
479                            .into_iter()
480                            .map(ChatCompletionMessageToolCalls::Function)
481                            .collect(),
482                    )
483                },
484                refusal: None,
485                audio: None,
486                #[allow(deprecated)]
487                function_call: None,
488            }
489            .into(),
490        );
491
492        let session = self
493            .sessions
494            .get_mut(session_id)
495            .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
496        session.history.push(assistant_msg);
497        let working_dir = session.working_dir.clone();
498
499        // If no tool calls, turn is complete
500        if assembled_tool_calls.is_empty() {
501            return Ok(false);
502        }
503
504        // Execute each tool call
505        for tc in &assembled_tool_calls {
506            tracing::info!(
507                "About to execute tool: id='{}', name='{}'",
508                tc.id,
509                tc.function.name
510            );
511
512            let tc_info = ToolCallInfo {
513                id: tc.id.clone(),
514                name: tc.function.name.clone(),
515                arguments: tc.function.arguments.clone(),
516            };
517
518            // Notify frontend
519            let _ = self
520                .frontend
521                .on_event(AgentEvent::ToolCallRequested {
522                    tool_call_id: tc_info.id.clone(),
523                    name: tc_info.name.clone(),
524                    arguments: tc_info.arguments.clone(),
525                })
526                .await;
527
528            // Check confirmation
529            let approved = if self.tools.requires_confirmation(&tc.function.name) && !self.auto_approve {
530                self.frontend.request_tool_confirmation(&tc_info).await?
531            } else {
532                true
533            };
534
535            // Execute or reject
536            let result = if approved {
537                let args: serde_json::Value = serde_json::from_str(&tc.function.arguments)
538                    .unwrap_or(serde_json::Value::Null);
539
540                let ctx = ToolContext {
541                    working_dir: working_dir.clone(),
542                    session_id: session_id.clone(),
543                    frontend: self.frontend.clone(),
544                    extensions: self.extensions.clone(),
545                };
546
547                self.tools.execute(&tc.function.name, args, &ctx).await
548            } else {
549                ToolResult::error("User rejected this tool call")
550            };
551
552            // Truncate output
553            let truncated_result = ToolResult {
554                content: self.context_manager.truncate_tool_output(&result.content),
555                is_error: result.is_error,
556            };
557
558            // Notify frontend of result
559            let _ = self
560                .frontend
561                .on_event(AgentEvent::ToolCallResult {
562                    tool_call_id: tc.id.clone(),
563                    result: truncated_result.clone(),
564                })
565                .await;
566
567            // Add tool result to history
568            let tool_msg = ChatCompletionRequestMessage::Tool(
569                ChatCompletionRequestToolMessage {
570                    content: truncated_result.content.into(),
571                    tool_call_id: tc.id.clone(),
572                }
573                .into(),
574            );
575
576            let session = self
577                .sessions
578                .get_mut(session_id)
579                .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
580            session.history.push(tool_msg);
581        }
582
583        Ok(true)
584    }
585
586    /// Clear the current session's history (keep system prompt).
587    fn clear_session(&mut self) {
588        if let Some(session) = self.sessions.get_mut(&self.default_session_id) {
589            session.history.truncate(1);
590        }
591    }
592
593    /// Build a user message, potentially with images if model supports them.
594    async fn build_user_message(
595        &self,
596        text: &str,
597        attachments: &[MediaAttachment],
598    ) -> ChatCompletionRequestMessage {
599        // If model supports images and we have image attachments, build multimodal message
600        if self.llm_client.supports_images()
601            && !attachments.is_empty()
602            && attachments.iter().any(|a| a.is_image())
603        {
604            self.build_multimodal_message(text, attachments)
605                .await
606        } else {
607            // Fallback: add attachment descriptions to text
608            let mut full_text = text.to_string();
609            for attachment in attachments {
610                full_text = format!("{}\n{}", full_text, attachment.describe());
611            }
612            ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
613                content: full_text.into(),
614                name: None,
615            })
616        }
617    }
618
619    /// Build a multimodal message with text + images.
620    async fn build_multimodal_message(
621        &self,
622        text: &str,
623        attachments: &[MediaAttachment],
624    ) -> ChatCompletionRequestMessage {
625        let mut parts = vec![ChatCompletionRequestUserMessageContentPart::Text(
626            ChatCompletionRequestMessageContentPartText {
627                text: text.to_string(),
628            },
629        )];
630
631        // Add images
632        for attachment in attachments {
633            if attachment.is_image() {
634                // Download and encode as base64
635                match media::download_and_encode_base64(
636                    &attachment.url,
637                    &attachment.content_type,
638                )
639                .await
640                {
641                    Ok(base64_url) => {
642                        parts.push(ChatCompletionRequestUserMessageContentPart::ImageUrl(
643                            ChatCompletionRequestMessageContentPartImage {
644                                image_url: ImageUrl {
645                                    url: base64_url,
646                                    detail: None,
647                                },
648                            },
649                        ));
650                    }
651                    Err(e) => {
652                        tracing::warn!("Failed to encode image: {}", e);
653                        // Fallback to description
654                        let desc = attachment.describe();
655                        let current_text = match &mut parts[0] {
656                            ChatCompletionRequestUserMessageContentPart::Text(t) => &mut t.text,
657                            _ => unreachable!(),
658                        };
659                        *current_text = format!("{}\n{}", current_text, desc);
660                    }
661                }
662            } else {
663                // Non-image: add description
664                let desc = attachment.describe();
665                let current_text = match &mut parts[0] {
666                    ChatCompletionRequestUserMessageContentPart::Text(t) => &mut t.text,
667                    _ => unreachable!(),
668                };
669                *current_text = format!("{}\n{}", current_text, desc);
670            }
671        }
672
673        ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
674            content: ChatCompletionRequestUserMessageContent::Array(parts),
675            name: None,
676        })
677    }
678
679    /// Execute a skill-triggered turn: inject skill content, then run the agent loop.
680    ///
681    /// The skill's full content is injected as a temporary system message and removed
682    /// after the turn completes, so it doesn't occupy context in future turns.
683    async fn run_skill_turn(&mut self, skill: &crate::skill::Skill, args: &str) {
684        // Notify frontend
685        let _ = self
686            .frontend
687            .on_event(AgentEvent::SkillTriggered {
688                name: skill.frontmatter.name.clone(),
689                description: skill.frontmatter.description.clone(),
690            })
691            .await;
692
693        let session_id = self.default_session_id.clone();
694
695        // Inject skill content as a system message
696        let skill_message = format!(
697            "## Skill: {}\n\n{}\n\n{}",
698            skill.frontmatter.name,
699            skill.frontmatter.description,
700            skill.content
701        );
702
703        let skill_msg = ChatCompletionRequestMessage::System(
704            ChatCompletionRequestSystemMessage {
705                content: skill_message.into(),
706                name: Some(skill.frontmatter.name.clone()),
707            }
708            .into(),
709        );
710
711        if let Some(session) = self.sessions.get_mut(&session_id) {
712            session.history.push(skill_msg);
713        }
714
715        // Add user message (args or default)
716        let user_content = if args.is_empty() {
717            "(User triggered skill, no additional arguments)".to_string()
718        } else {
719            args.to_string()
720        };
721
722        if let Some(session) = self.sessions.get_mut(&session_id) {
723            session.history.push(ChatCompletionRequestMessage::User(
724                ChatCompletionRequestUserMessage {
725                    content: user_content.into(),
726                    name: None,
727                }
728                .into(),
729            ));
730        }
731
732        // Run the agentic loop
733        let max_iterations = 20;
734        let mut completed = false;
735        for iteration in 0..max_iterations {
736            match self.run_one_step(&session_id).await {
737                Ok(has_tool_calls) => {
738                    if !has_tool_calls {
739                        completed = true;
740                        break;
741                    }
742                    tracing::debug!(
743                        "Skill iteration {}: tool calls executed",
744                        iteration
745                    );
746                }
747                Err(e) => {
748                    let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
749                    break;
750                }
751            }
752        }
753
754        if !completed {
755            let _ = self
756                .frontend
757                .on_event(AgentEvent::Error(AgentError::InternalError(
758                    format!("Max iterations reached ({})", max_iterations),
759                )))
760                .await;
761        }
762
763        let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
764
765        // Remove the injected skill system message to avoid polluting future turns
766        if let Some(session) = self.sessions.get_mut(&session_id) {
767            let skill_name = skill.frontmatter.name.clone();
768            session.history.retain(|msg| {
769                !matches!(
770                    msg,
771                    ChatCompletionRequestMessage::System(s)
772                        if s.name.as_deref() == Some(&skill_name)
773                )
774            });
775        }
776    }
777}
778
779// ============================================================================
780// Helper types
781// ============================================================================
782
783/// Accumulates streaming tool call chunks.
784#[derive(Debug)]
785struct ToolCallAccumulator {
786    id: Option<String>,
787    name: Option<String>,
788    arguments: String,
789}
790
791impl ToolCallAccumulator {
792    fn new() -> Self {
793        Self {
794            id: None,
795            name: None,
796            arguments: String::new(),
797        }
798    }
799
800    /// Convert accumulated chunks into a complete tool call.
801    fn into_tool_call(self) -> Option<ChatCompletionMessageToolCall> {
802        tracing::debug!("Converting accumulator to tool call: {:?}", self);
803
804        let id = self.id?;
805        let name = self.name?;
806
807        tracing::debug!("Tool call assembled: id='{}', name='{}', args='{}'", id, name, self.arguments);
808
809        Some(ChatCompletionMessageToolCall {
810            id,
811            function: FunctionCall {
812                name,
813                arguments: self.arguments,
814            },
815        })
816    }
817}