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                        let acc = tool_call_chunks
285                            .entry(tc.index as usize)
286                            .or_insert_with(ToolCallAccumulator::new);
287
288                        if let Some(id) = &tc.id {
289                            acc.id = Some(id.clone());
290                        }
291                        if let Some(function) = &tc.function {
292                            if let Some(name) = &function.name {
293                                acc.name = Some(name.clone());
294                            }
295                            if let Some(args) = &function.arguments {
296                                acc.arguments.push_str(args);
297                            }
298                        }
299                    }
300                }
301            }
302        }
303
304        // Assemble complete tool calls from chunks
305        let assembled_tool_calls: Vec<ChatCompletionMessageToolCall> = {
306            let mut indices: Vec<usize> = tool_call_chunks.keys().cloned().collect();
307            indices.sort();
308            indices
309                .into_iter()
310                .filter_map(|idx| tool_call_chunks.remove(&idx)?.into_tool_call())
311                .collect()
312        };
313
314        // Add assistant message to history
315        let assistant_msg = ChatCompletionRequestMessage::Assistant(
316            ChatCompletionRequestAssistantMessage {
317                content: if full_text.is_empty() {
318                    None
319                } else {
320                    Some(full_text.clone().into())
321                },
322                name: None,
323                tool_calls: if assembled_tool_calls.is_empty() {
324                    None
325                } else {
326                    Some(
327                        assembled_tool_calls
328                            .clone()
329                            .into_iter()
330                            .map(ChatCompletionMessageToolCalls::Function)
331                            .collect(),
332                    )
333                },
334                refusal: None,
335                audio: None,
336                #[allow(deprecated)]
337                function_call: None,
338            }
339            .into(),
340        );
341
342        let session = self
343            .sessions
344            .get_mut(session_id)
345            .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
346        session.history.push(assistant_msg);
347        let working_dir = session.working_dir.clone();
348
349        // If no tool calls, turn is complete
350        if assembled_tool_calls.is_empty() {
351            return Ok(false);
352        }
353
354        // Execute each tool call
355        for tc in &assembled_tool_calls {
356            let tc_info = ToolCallInfo {
357                id: tc.id.clone(),
358                name: tc.function.name.clone(),
359                arguments: tc.function.arguments.clone(),
360            };
361
362            // Notify frontend
363            let _ = self
364                .frontend
365                .on_event(AgentEvent::ToolCallRequested {
366                    tool_call_id: tc_info.id.clone(),
367                    name: tc_info.name.clone(),
368                    arguments: tc_info.arguments.clone(),
369                })
370                .await;
371
372            // Check confirmation
373            let approved = if self.tools.requires_confirmation(&tc.function.name) && !self.auto_approve {
374                self.frontend.request_tool_confirmation(&tc_info).await?
375            } else {
376                true
377            };
378
379            // Execute or reject
380            let result = if approved {
381                let args: serde_json::Value = serde_json::from_str(&tc.function.arguments)
382                    .unwrap_or(serde_json::Value::Null);
383
384                let ctx = ToolContext {
385                    working_dir: working_dir.clone(),
386                    session_id: session_id.clone(),
387                    frontend: self.frontend.clone(),
388                    extensions: self.extensions.clone(),
389                };
390
391                self.tools.execute(&tc.function.name, args, &ctx).await
392            } else {
393                ToolResult::error("User rejected this tool call")
394            };
395
396            // Truncate output
397            let truncated_result = ToolResult {
398                content: self.context_manager.truncate_tool_output(&result.content),
399                is_error: result.is_error,
400            };
401
402            // Notify frontend of result
403            let _ = self
404                .frontend
405                .on_event(AgentEvent::ToolCallResult {
406                    tool_call_id: tc.id.clone(),
407                    result: truncated_result.clone(),
408                })
409                .await;
410
411            // Add tool result to history
412            let tool_msg = ChatCompletionRequestMessage::Tool(
413                ChatCompletionRequestToolMessage {
414                    content: truncated_result.content.into(),
415                    tool_call_id: tc.id.clone(),
416                }
417                .into(),
418            );
419
420            let session = self
421                .sessions
422                .get_mut(session_id)
423                .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
424            session.history.push(tool_msg);
425        }
426
427        Ok(true)
428    }
429
430    /// Clear the current session's history (keep system prompt).
431    fn clear_session(&mut self) {
432        if let Some(session) = self.sessions.get_mut(&self.default_session_id) {
433            session.history.truncate(1);
434        }
435    }
436
437    /// Build a user message, potentially with images if model supports them.
438    async fn build_user_message(
439        &self,
440        text: &str,
441        attachments: &[MediaAttachment],
442    ) -> ChatCompletionRequestMessage {
443        // If model supports images and we have image attachments, build multimodal message
444        if self.llm_client.supports_images()
445            && !attachments.is_empty()
446            && attachments.iter().any(|a| a.is_image())
447        {
448            self.build_multimodal_message(text, attachments)
449                .await
450        } else {
451            // Fallback: add attachment descriptions to text
452            let mut full_text = text.to_string();
453            for attachment in attachments {
454                full_text = format!("{}\n{}", full_text, attachment.describe());
455            }
456            ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
457                content: full_text.into(),
458                name: None,
459            })
460        }
461    }
462
463    /// Build a multimodal message with text + images.
464    async fn build_multimodal_message(
465        &self,
466        text: &str,
467        attachments: &[MediaAttachment],
468    ) -> ChatCompletionRequestMessage {
469        let mut parts = vec![ChatCompletionRequestUserMessageContentPart::Text(
470            ChatCompletionRequestMessageContentPartText {
471                text: text.to_string(),
472            },
473        )];
474
475        // Add images
476        for attachment in attachments {
477            if attachment.is_image() {
478                // Download and encode as base64
479                match media::download_and_encode_base64(
480                    &attachment.url,
481                    &attachment.content_type,
482                )
483                .await
484                {
485                    Ok(base64_url) => {
486                        parts.push(ChatCompletionRequestUserMessageContentPart::ImageUrl(
487                            ChatCompletionRequestMessageContentPartImage {
488                                image_url: ImageUrl {
489                                    url: base64_url,
490                                    detail: None,
491                                },
492                            },
493                        ));
494                    }
495                    Err(e) => {
496                        tracing::warn!("Failed to encode image: {}", e);
497                        // Fallback to description
498                        let desc = attachment.describe();
499                        let current_text = match &mut parts[0] {
500                            ChatCompletionRequestUserMessageContentPart::Text(t) => &mut t.text,
501                            _ => unreachable!(),
502                        };
503                        *current_text = format!("{}\n{}", current_text, desc);
504                    }
505                }
506            } else {
507                // Non-image: add description
508                let desc = attachment.describe();
509                let current_text = match &mut parts[0] {
510                    ChatCompletionRequestUserMessageContentPart::Text(t) => &mut t.text,
511                    _ => unreachable!(),
512                };
513                *current_text = format!("{}\n{}", current_text, desc);
514            }
515        }
516
517        ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
518            content: ChatCompletionRequestUserMessageContent::Array(parts),
519            name: None,
520        })
521    }
522
523    /// Execute a skill-triggered turn: inject skill content, then run the agent loop.
524    ///
525    /// The skill's full content is injected as a temporary system message and removed
526    /// after the turn completes, so it doesn't occupy context in future turns.
527    async fn run_skill_turn(&mut self, skill: &crate::skill::Skill, args: &str) {
528        // Notify frontend
529        let _ = self
530            .frontend
531            .on_event(AgentEvent::SkillTriggered {
532                name: skill.frontmatter.name.clone(),
533                description: skill.frontmatter.description.clone(),
534            })
535            .await;
536
537        let session_id = self.default_session_id.clone();
538
539        // Inject skill content as a system message
540        let skill_message = format!(
541            "## Skill: {}\n\n{}\n\n{}",
542            skill.frontmatter.name,
543            skill.frontmatter.description,
544            skill.content
545        );
546
547        let skill_msg = ChatCompletionRequestMessage::System(
548            ChatCompletionRequestSystemMessage {
549                content: skill_message.into(),
550                name: Some(skill.frontmatter.name.clone()),
551            }
552            .into(),
553        );
554
555        if let Some(session) = self.sessions.get_mut(&session_id) {
556            session.history.push(skill_msg);
557        }
558
559        // Add user message (args or default)
560        let user_content = if args.is_empty() {
561            "(User triggered skill, no additional arguments)".to_string()
562        } else {
563            args.to_string()
564        };
565
566        if let Some(session) = self.sessions.get_mut(&session_id) {
567            session.history.push(ChatCompletionRequestMessage::User(
568                ChatCompletionRequestUserMessage {
569                    content: user_content.into(),
570                    name: None,
571                }
572                .into(),
573            ));
574        }
575
576        // Run the agentic loop
577        let max_iterations = 20;
578        let mut completed = false;
579        for iteration in 0..max_iterations {
580            match self.run_one_step(&session_id).await {
581                Ok(has_tool_calls) => {
582                    if !has_tool_calls {
583                        completed = true;
584                        break;
585                    }
586                    tracing::debug!(
587                        "Skill iteration {}: tool calls executed",
588                        iteration
589                    );
590                }
591                Err(e) => {
592                    let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
593                    break;
594                }
595            }
596        }
597
598        if !completed {
599            let _ = self
600                .frontend
601                .on_event(AgentEvent::Error(AgentError::InternalError(
602                    format!("Max iterations reached ({})", max_iterations),
603                )))
604                .await;
605        }
606
607        let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
608
609        // Remove the injected skill system message to avoid polluting future turns
610        if let Some(session) = self.sessions.get_mut(&session_id) {
611            let skill_name = skill.frontmatter.name.clone();
612            session.history.retain(|msg| {
613                !matches!(
614                    msg,
615                    ChatCompletionRequestMessage::System(s)
616                        if s.name.as_deref() == Some(&skill_name)
617                )
618            });
619        }
620    }
621}
622
623// ============================================================================
624// Helper types
625// ============================================================================
626
627/// Accumulates streaming tool call chunks.
628struct ToolCallAccumulator {
629    id: Option<String>,
630    name: Option<String>,
631    arguments: String,
632}
633
634impl ToolCallAccumulator {
635    fn new() -> Self {
636        Self {
637            id: None,
638            name: None,
639            arguments: String::new(),
640        }
641    }
642
643    /// Convert accumulated chunks into a complete tool call.
644    fn into_tool_call(self) -> Option<ChatCompletionMessageToolCall> {
645        let id = self.id?;
646        let name = self.name?;
647        Some(ChatCompletionMessageToolCall {
648            id,
649            function: FunctionCall {
650                name,
651                arguments: self.arguments,
652            },
653        })
654    }
655}