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