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        let prompt_builder = PromptBuilder::with_working_dir(Some(&working_dir));
164        let context_manager = ContextManager::new(context_window, context_config);
165
166        // Build system prompt with tools AND skills
167        let tool_refs: Vec<&dyn crate::tool::Tool> = tools.tools();
168        let skill_descs = skills.skill_descriptions();
169        let system_prompt = prompt_builder.build_system_prompt(&tool_refs, &skill_descs, &working_dir);
170
171        // Create session with history
172        let mut session = AgentSession::with_history(
173            session_id.clone(),
174            working_dir,
175            system_prompt,
176            history,
177        );
178
179        // Apply context truncation before starting
180        let _truncation_result = context_manager.maybe_truncate(&mut session.history);
181
182        let mut sessions = HashMap::new();
183        sessions.insert(session_id.clone(), session);
184
185        Self {
186            llm_client,
187            tools,
188            skills,
189            sessions,
190            default_session_id: session_id,
191            context_manager,
192            frontend,
193            auto_approve,
194            extensions,
195        }
196    }
197
198    /// Run the agent's main event loop. Takes ownership of the message receiver.
199    /// Returns when the channel is closed or user types /exit.
200    pub async fn run(mut self, mut message_rx: mpsc::Receiver<FrontendMessage>) {
201        tracing::info!("Agent started, session: {}", self.default_session_id);
202
203        while let Some(msg) = message_rx.recv().await {
204            match msg {
205                FrontendMessage::UserInput { text, attachments } => {
206                    if text == "/exit" || text == "/quit" {
207                        break;
208                    }
209                    if text == "/clear" {
210                        self.clear_session();
211                        let _ = self
212                            .frontend
213                            .on_event(AgentEvent::TextDelta(
214                                "\n[Conversation history cleared]\n".to_string(),
215                            ))
216                            .await;
217                        let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
218                        continue;
219                    }
220
221                    // Check for skill trigger
222                    if let Some((skill, args)) = self.skills.match_trigger(&text) {
223                        let skill = skill.clone();
224                        self.run_skill_turn(&skill, &args).await;
225                        continue;
226                    }
227
228                    self.run_turn(&text, attachments).await;
229                }
230                FrontendMessage::Cancel => {
231                    tracing::info!("Cancel requested (MVP: no-op)");
232                }
233                FrontendMessage::ConfirmationResponse { .. } => {
234                    // Confirmation is handled via frontend.request_tool_confirmation()
235                    // within run_one_step. This variant is reserved for future async flow.
236                    tracing::warn!("Unexpected ConfirmationResponse outside tool confirmation");
237                }
238            }
239        }
240
241        tracing::info!("Agent stopped");
242    }
243
244    /// Execute a single turn: user input -> LLM call(s) -> tool execution(s) -> response.
245    async fn run_turn(&mut self, user_input: &str, attachments: Vec<MediaAttachment>) {
246        let session_id = self.default_session_id.clone();
247
248        // Build user message first (to avoid borrow conflict)
249        let user_message = self.build_user_message(user_input, &attachments).await;
250
251        // Add user message to history
252        if let Some(session) = self.sessions.get_mut(&session_id) {
253            session.history.push(user_message);
254        }
255
256        // Run the agentic loop (may iterate if LLM calls tools)
257        let max_iterations = 20;
258        for iteration in 0..max_iterations {
259            match self.run_one_step(&session_id).await {
260                Ok(has_tool_calls) => {
261                    if !has_tool_calls {
262                        let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
263                        return;
264                    }
265                    tracing::debug!(
266                        "Iteration {}: tool calls executed, continuing loop",
267                        iteration
268                    );
269                }
270                Err(e) => {
271                    let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
272                    let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
273                    return;
274                }
275            }
276        }
277
278        // Safety limit
279        let _ = self
280            .frontend
281            .on_event(AgentEvent::Error(AgentError::InternalError(
282                format!("Max iterations reached ({})", max_iterations),
283            )))
284            .await;
285        let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
286    }
287
288    /// Run one step: call LLM, process response, execute tools.
289    /// Returns Ok(true) if tool calls were made (loop should continue).
290    /// Returns Ok(false) if the LLM responded with text only (turn complete).
291    async fn run_one_step(&mut self, session_id: &SessionId) -> Result<bool> {
292        let session = self
293            .sessions
294            .get_mut(session_id)
295            .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
296
297        // Truncate context if needed
298        let truncation_result = self.context_manager.maybe_truncate(&mut session.history);
299
300        // Handle async compression if needed
301        if truncation_result.needs_compression {
302            // For now, replace placeholder with a notice
303            // In production, spawn async task to call LLM and replace with summary
304            if let Some(msg) = session.history.get_mut(truncation_result.insert_position) {
305                let notice = format!(
306                    "[Omitted {} rounds, {} messages. Context compressed to save space]",
307                    truncation_result.rounds_removed,
308                    truncation_result.messages_removed
309                );
310                *msg = ChatCompletionRequestMessage::User(
311                    ChatCompletionRequestUserMessage {
312                        content: notice.into(),
313                        name: Some("system_notice".to_string()),
314                    }
315                    .into(),
316                );
317            }
318
319            tracing::info!(
320                "Compression triggered: removed {} tokens (threshold: {})",
321                crate::context::estimate_messages_tokens(&truncation_result.removed_messages),
322                self.context_manager.compression_token_threshold
323            );
324        }
325
326        // Build tool schemas
327        let tool_schemas = self.tools.tool_schemas();
328        let tools_param = if tool_schemas.is_empty() {
329            None
330        } else {
331            Some(tool_schemas)
332        };
333
334        // Call LLM (streaming)
335        let mut stream = self
336            .llm_client
337            .chat_stream(session.history.clone(), tools_param)
338            .await?;
339
340        // Collect streaming response
341        let mut full_text = String::new();
342        let mut tool_call_chunks: HashMap<usize, ToolCallAccumulator> = HashMap::new();
343
344        while let Some(chunk_result) = stream.next().await {
345            let chunk = chunk_result.map_err(|e| AgentError::LlmError(e.into()))?;
346
347            if let Some(choice) = chunk.choices.first() {
348                // Text content
349                if let Some(content) = &choice.delta.content {
350                    full_text.push_str(content);
351                    let _ = self
352                        .frontend
353                        .on_event(AgentEvent::TextDelta(content.clone()))
354                        .await;
355                }
356
357                // Tool call deltas
358                if let Some(tool_calls) = &choice.delta.tool_calls {
359                    for tc in tool_calls {
360                        tracing::debug!(
361                            "Received tool call chunk: index={}, id={:?}, function={:?}",
362                            tc.index,
363                            tc.id,
364                            tc.function
365                        );
366
367                        let acc = tool_call_chunks
368                            .entry(tc.index as usize)
369                            .or_insert_with(ToolCallAccumulator::new);
370
371                        if let Some(id) = &tc.id {
372                            // 只有当id非空时才更新
373                            if !id.is_empty() {
374                                tracing::debug!("Updating tool id: '{}'", id);
375                                acc.id = Some(id.clone());
376                            }
377                        }
378                        if let Some(function) = &tc.function {
379                            if let Some(name) = &function.name {
380                                // 只有当name非空时才更新
381                                if !name.is_empty() {
382                                    tracing::debug!("Tool name chunk: '{}'", name);
383                                    acc.name = Some(name.clone());
384                                }
385                            }
386                            if let Some(args) = &function.arguments {
387                                tracing::debug!("Tool args chunk: '{}'", args);
388                                acc.arguments.push_str(args);
389                            }
390                        }
391
392                        tracing::debug!("Accumulator state after chunk: {:?}", acc);
393                    }
394                }
395            }
396        }
397
398        // Assemble complete tool calls from chunks
399        let assembled_tool_calls: Vec<ChatCompletionMessageToolCall> = {
400            let mut indices: Vec<usize> = tool_call_chunks.keys().cloned().collect();
401            indices.sort();
402            indices
403                .into_iter()
404                .filter_map(|idx| tool_call_chunks.remove(&idx)?.into_tool_call())
405                .collect()
406        };
407
408        tracing::debug!("Assembled {} tool call(s)", assembled_tool_calls.len());
409        for (i, tc) in assembled_tool_calls.iter().enumerate() {
410            tracing::debug!(
411                "Tool call [{}]: id='{}', name='{}', arguments='{}'",
412                i,
413                tc.id,
414                tc.function.name,
415                tc.function.arguments
416            );
417        }
418
419        // Add assistant message to history
420        let assistant_msg = ChatCompletionRequestMessage::Assistant(
421            ChatCompletionRequestAssistantMessage {
422                content: if full_text.is_empty() {
423                    None
424                } else {
425                    Some(full_text.clone().into())
426                },
427                name: None,
428                tool_calls: if assembled_tool_calls.is_empty() {
429                    None
430                } else {
431                    Some(
432                        assembled_tool_calls
433                            .clone()
434                            .into_iter()
435                            .map(ChatCompletionMessageToolCalls::Function)
436                            .collect(),
437                    )
438                },
439                refusal: None,
440                audio: None,
441                #[allow(deprecated)]
442                function_call: None,
443            }
444            .into(),
445        );
446
447        let session = self
448            .sessions
449            .get_mut(session_id)
450            .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
451        session.history.push(assistant_msg);
452        let working_dir = session.working_dir.clone();
453
454        // If no tool calls, turn is complete
455        if assembled_tool_calls.is_empty() {
456            return Ok(false);
457        }
458
459        // Execute each tool call
460        for tc in &assembled_tool_calls {
461            tracing::info!(
462                "About to execute tool: id='{}', name='{}'",
463                tc.id,
464                tc.function.name
465            );
466
467            let tc_info = ToolCallInfo {
468                id: tc.id.clone(),
469                name: tc.function.name.clone(),
470                arguments: tc.function.arguments.clone(),
471            };
472
473            // Notify frontend
474            let _ = self
475                .frontend
476                .on_event(AgentEvent::ToolCallRequested {
477                    tool_call_id: tc_info.id.clone(),
478                    name: tc_info.name.clone(),
479                    arguments: tc_info.arguments.clone(),
480                })
481                .await;
482
483            // Check confirmation
484            let approved = if self.tools.requires_confirmation(&tc.function.name) && !self.auto_approve {
485                self.frontend.request_tool_confirmation(&tc_info).await?
486            } else {
487                true
488            };
489
490            // Execute or reject
491            let result = if approved {
492                let args: serde_json::Value = serde_json::from_str(&tc.function.arguments)
493                    .unwrap_or(serde_json::Value::Null);
494
495                let ctx = ToolContext {
496                    working_dir: working_dir.clone(),
497                    session_id: session_id.clone(),
498                    frontend: self.frontend.clone(),
499                    extensions: self.extensions.clone(),
500                };
501
502                self.tools.execute(&tc.function.name, args, &ctx).await
503            } else {
504                ToolResult::error("User rejected this tool call")
505            };
506
507            // Truncate output
508            let truncated_result = ToolResult {
509                content: self.context_manager.truncate_tool_output(&result.content),
510                is_error: result.is_error,
511            };
512
513            // Notify frontend of result
514            let _ = self
515                .frontend
516                .on_event(AgentEvent::ToolCallResult {
517                    tool_call_id: tc.id.clone(),
518                    result: truncated_result.clone(),
519                })
520                .await;
521
522            // Add tool result to history
523            let tool_msg = ChatCompletionRequestMessage::Tool(
524                ChatCompletionRequestToolMessage {
525                    content: truncated_result.content.into(),
526                    tool_call_id: tc.id.clone(),
527                }
528                .into(),
529            );
530
531            let session = self
532                .sessions
533                .get_mut(session_id)
534                .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
535            session.history.push(tool_msg);
536        }
537
538        Ok(true)
539    }
540
541    /// Clear the current session's history (keep system prompt).
542    fn clear_session(&mut self) {
543        if let Some(session) = self.sessions.get_mut(&self.default_session_id) {
544            session.history.truncate(1);
545        }
546    }
547
548    /// Build a user message, potentially with images if model supports them.
549    async fn build_user_message(
550        &self,
551        text: &str,
552        attachments: &[MediaAttachment],
553    ) -> ChatCompletionRequestMessage {
554        // If model supports images and we have image attachments, build multimodal message
555        if self.llm_client.supports_images()
556            && !attachments.is_empty()
557            && attachments.iter().any(|a| a.is_image())
558        {
559            self.build_multimodal_message(text, attachments)
560                .await
561        } else {
562            // Fallback: add attachment descriptions to text
563            let mut full_text = text.to_string();
564            for attachment in attachments {
565                full_text = format!("{}\n{}", full_text, attachment.describe());
566            }
567            ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
568                content: full_text.into(),
569                name: None,
570            })
571        }
572    }
573
574    /// Build a multimodal message with text + images.
575    async fn build_multimodal_message(
576        &self,
577        text: &str,
578        attachments: &[MediaAttachment],
579    ) -> ChatCompletionRequestMessage {
580        let mut parts = vec![ChatCompletionRequestUserMessageContentPart::Text(
581            ChatCompletionRequestMessageContentPartText {
582                text: text.to_string(),
583            },
584        )];
585
586        // Add images
587        for attachment in attachments {
588            if attachment.is_image() {
589                // Download and encode as base64
590                match media::download_and_encode_base64(
591                    &attachment.url,
592                    &attachment.content_type,
593                )
594                .await
595                {
596                    Ok(base64_url) => {
597                        parts.push(ChatCompletionRequestUserMessageContentPart::ImageUrl(
598                            ChatCompletionRequestMessageContentPartImage {
599                                image_url: ImageUrl {
600                                    url: base64_url,
601                                    detail: None,
602                                },
603                            },
604                        ));
605                    }
606                    Err(e) => {
607                        tracing::warn!("Failed to encode image: {}", e);
608                        // Fallback to description
609                        let desc = attachment.describe();
610                        let current_text = match &mut parts[0] {
611                            ChatCompletionRequestUserMessageContentPart::Text(t) => &mut t.text,
612                            _ => unreachable!(),
613                        };
614                        *current_text = format!("{}\n{}", current_text, desc);
615                    }
616                }
617            } else {
618                // Non-image: add description
619                let desc = attachment.describe();
620                let current_text = match &mut parts[0] {
621                    ChatCompletionRequestUserMessageContentPart::Text(t) => &mut t.text,
622                    _ => unreachable!(),
623                };
624                *current_text = format!("{}\n{}", current_text, desc);
625            }
626        }
627
628        ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
629            content: ChatCompletionRequestUserMessageContent::Array(parts),
630            name: None,
631        })
632    }
633
634    /// Execute a skill-triggered turn: inject skill content, then run the agent loop.
635    ///
636    /// The skill's full content is injected as a temporary system message and removed
637    /// after the turn completes, so it doesn't occupy context in future turns.
638    async fn run_skill_turn(&mut self, skill: &crate::skill::Skill, args: &str) {
639        // Notify frontend
640        let _ = self
641            .frontend
642            .on_event(AgentEvent::SkillTriggered {
643                name: skill.frontmatter.name.clone(),
644                description: skill.frontmatter.description.clone(),
645            })
646            .await;
647
648        let session_id = self.default_session_id.clone();
649
650        // Inject skill content as a system message
651        let skill_message = format!(
652            "## Skill: {}\n\n{}\n\n{}",
653            skill.frontmatter.name,
654            skill.frontmatter.description,
655            skill.content
656        );
657
658        let skill_msg = ChatCompletionRequestMessage::System(
659            ChatCompletionRequestSystemMessage {
660                content: skill_message.into(),
661                name: Some(skill.frontmatter.name.clone()),
662            }
663            .into(),
664        );
665
666        if let Some(session) = self.sessions.get_mut(&session_id) {
667            session.history.push(skill_msg);
668        }
669
670        // Add user message (args or default)
671        let user_content = if args.is_empty() {
672            "(User triggered skill, no additional arguments)".to_string()
673        } else {
674            args.to_string()
675        };
676
677        if let Some(session) = self.sessions.get_mut(&session_id) {
678            session.history.push(ChatCompletionRequestMessage::User(
679                ChatCompletionRequestUserMessage {
680                    content: user_content.into(),
681                    name: None,
682                }
683                .into(),
684            ));
685        }
686
687        // Run the agentic loop
688        let max_iterations = 20;
689        let mut completed = false;
690        for iteration in 0..max_iterations {
691            match self.run_one_step(&session_id).await {
692                Ok(has_tool_calls) => {
693                    if !has_tool_calls {
694                        completed = true;
695                        break;
696                    }
697                    tracing::debug!(
698                        "Skill iteration {}: tool calls executed",
699                        iteration
700                    );
701                }
702                Err(e) => {
703                    let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
704                    break;
705                }
706            }
707        }
708
709        if !completed {
710            let _ = self
711                .frontend
712                .on_event(AgentEvent::Error(AgentError::InternalError(
713                    format!("Max iterations reached ({})", max_iterations),
714                )))
715                .await;
716        }
717
718        let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
719
720        // Remove the injected skill system message to avoid polluting future turns
721        if let Some(session) = self.sessions.get_mut(&session_id) {
722            let skill_name = skill.frontmatter.name.clone();
723            session.history.retain(|msg| {
724                !matches!(
725                    msg,
726                    ChatCompletionRequestMessage::System(s)
727                        if s.name.as_deref() == Some(&skill_name)
728                )
729            });
730        }
731    }
732}
733
734// ============================================================================
735// Helper types
736// ============================================================================
737
738/// Accumulates streaming tool call chunks.
739#[derive(Debug)]
740struct ToolCallAccumulator {
741    id: Option<String>,
742    name: Option<String>,
743    arguments: String,
744}
745
746impl ToolCallAccumulator {
747    fn new() -> Self {
748        Self {
749            id: None,
750            name: None,
751            arguments: String::new(),
752        }
753    }
754
755    /// Convert accumulated chunks into a complete tool call.
756    fn into_tool_call(self) -> Option<ChatCompletionMessageToolCall> {
757        tracing::debug!("Converting accumulator to tool call: {:?}", self);
758
759        let id = self.id?;
760        let name = self.name?;
761
762        tracing::debug!("Tool call assembled: id='{}', name='{}', args='{}'", id, name, self.arguments);
763
764        Some(ChatCompletionMessageToolCall {
765            id,
766            function: FunctionCall {
767                name,
768                arguments: self.arguments,
769            },
770        })
771    }
772}