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, FunctionCall,
8};
9use futures::StreamExt;
10use robit_ai::config::ContextConfig;
11use robit_ai::LlmClient;
12use std::collections::HashMap;
13use std::path::PathBuf;
14use std::sync::Arc;
15use tokio::sync::mpsc;
16
17use crate::context::ContextManager;
18use crate::error::{AgentError, Result};
19use crate::event::{new_session_id, AgentEvent, FrontendMessage, SessionId};
20use crate::frontend::Frontend;
21use crate::prompt::PromptBuilder;
22use crate::skill::SkillRegistry;
23use crate::tool::{ToolCallInfo, ToolContext, ToolRegistry, ToolResult};
24
25// ============================================================================
26// AgentSession
27// ============================================================================
28
29/// A single conversation session with its own message history.
30pub struct AgentSession {
31    pub session_id: SessionId,
32    pub history: Vec<ChatCompletionRequestMessage>,
33    pub working_dir: PathBuf,
34}
35
36impl AgentSession {
37    fn new(session_id: SessionId, working_dir: PathBuf, system_prompt: String) -> Self {
38        let system_msg = ChatCompletionRequestMessage::System(
39            ChatCompletionRequestSystemMessage {
40                content: system_prompt.into(),
41                name: None,
42            }
43            .into(),
44        );
45
46        Self {
47            session_id,
48            history: vec![system_msg],
49            working_dir,
50        }
51    }
52}
53
54// ============================================================================
55// Agent
56// ============================================================================
57
58/// The Agent orchestrates LLM calls and tool execution.
59pub struct Agent {
60    llm_client: Arc<LlmClient>,
61    tools: Arc<ToolRegistry>,
62    skills: Arc<SkillRegistry>,
63    sessions: HashMap<SessionId, AgentSession>,
64    default_session_id: SessionId,
65    context_manager: ContextManager,
66    frontend: Arc<dyn Frontend>,
67    auto_approve: bool,
68}
69
70impl Agent {
71    /// Create a new Agent with the given dependencies.
72    pub fn new(
73        llm_client: Arc<LlmClient>,
74        tools: Arc<ToolRegistry>,
75        skills: Arc<SkillRegistry>,
76        frontend: Arc<dyn Frontend>,
77        context_config: Option<&ContextConfig>,
78        context_window: Option<u64>,
79        working_dir: PathBuf,
80        auto_approve: bool,
81    ) -> Self {
82        let prompt_builder = PromptBuilder::new();
83        let context_manager = ContextManager::new(context_window, context_config);
84
85        // Build system prompt with tools AND skills
86        let tool_refs: Vec<&dyn crate::tool::Tool> = tools.tools();
87        let skill_descs = skills.skill_descriptions();
88        let system_prompt = prompt_builder.build_system_prompt(&tool_refs, &skill_descs, &working_dir);
89
90        // Create default session
91        let session_id = new_session_id();
92        let session = AgentSession::new(session_id.clone(), working_dir, system_prompt);
93
94        let mut sessions = HashMap::new();
95        sessions.insert(session_id.clone(), session);
96
97        Self {
98            llm_client,
99            tools,
100            skills,
101            sessions,
102            default_session_id: session_id,
103            context_manager,
104            frontend,
105            auto_approve,
106        }
107    }
108
109    /// Run the agent's main event loop. Takes ownership of the message receiver.
110    /// Returns when the channel is closed or user types /exit.
111    pub async fn run(mut self, mut message_rx: mpsc::Receiver<FrontendMessage>) {
112        tracing::info!("Agent started, session: {}", self.default_session_id);
113
114        while let Some(msg) = message_rx.recv().await {
115            match msg {
116                FrontendMessage::UserInput(input) => {
117                    if input == "/exit" || input == "/quit" {
118                        break;
119                    }
120                    if input == "/clear" {
121                        self.clear_session();
122                        let _ = self
123                            .frontend
124                            .on_event(AgentEvent::TextDelta(
125                                "\n[Conversation history cleared]\n".to_string(),
126                            ))
127                            .await;
128                        let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
129                        continue;
130                    }
131
132                    // Check for skill trigger
133                    if let Some((skill, args)) = self.skills.match_trigger(&input) {
134                        let skill = skill.clone();
135                        self.run_skill_turn(&skill, &args).await;
136                        continue;
137                    }
138
139                    self.run_turn(&input).await;
140                }
141                FrontendMessage::Cancel => {
142                    tracing::info!("Cancel requested (MVP: no-op)");
143                }
144                FrontendMessage::ConfirmationResponse { .. } => {
145                    // Confirmation is handled via frontend.request_tool_confirmation()
146                    // within run_one_step. This variant is reserved for future async flow.
147                    tracing::warn!("Unexpected ConfirmationResponse outside tool confirmation");
148                }
149            }
150        }
151
152        tracing::info!("Agent stopped");
153    }
154
155    /// Execute a single turn: user input -> LLM call(s) -> tool execution(s) -> response.
156    async fn run_turn(&mut self, user_input: &str) {
157        let session_id = self.default_session_id.clone();
158
159        // Add user message to history
160        if let Some(session) = self.sessions.get_mut(&session_id) {
161            session.history.push(ChatCompletionRequestMessage::User(
162                ChatCompletionRequestUserMessage {
163                    content: user_input.to_string().into(),
164                    name: None,
165                }
166                .into(),
167            ));
168        }
169
170        // Run the agentic loop (may iterate if LLM calls tools)
171        let max_iterations = 20;
172        for iteration in 0..max_iterations {
173            match self.run_one_step(&session_id).await {
174                Ok(has_tool_calls) => {
175                    if !has_tool_calls {
176                        let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
177                        return;
178                    }
179                    tracing::debug!(
180                        "Iteration {}: tool calls executed, continuing loop",
181                        iteration
182                    );
183                }
184                Err(e) => {
185                    let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
186                    let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
187                    return;
188                }
189            }
190        }
191
192        // Safety limit
193        let _ = self
194            .frontend
195            .on_event(AgentEvent::Error(AgentError::InternalError(
196                format!("Max iterations reached ({})", max_iterations),
197            )))
198            .await;
199        let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
200    }
201
202    /// Run one step: call LLM, process response, execute tools.
203    /// Returns Ok(true) if tool calls were made (loop should continue).
204    /// Returns Ok(false) if the LLM responded with text only (turn complete).
205    async fn run_one_step(&mut self, session_id: &SessionId) -> Result<bool> {
206        let session = self
207            .sessions
208            .get_mut(session_id)
209            .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
210
211        // Truncate context if needed
212        let truncation_result = self.context_manager.maybe_truncate(&mut session.history);
213
214        // Handle async compression if needed
215        if truncation_result.needs_compression {
216            // For now, replace placeholder with a notice
217            // In production, spawn async task to call LLM and replace with summary
218            if let Some(msg) = session.history.get_mut(truncation_result.insert_position) {
219                let notice = format!(
220                    "[Omitted {} rounds, {} messages. Context compressed to save space]",
221                    truncation_result.rounds_removed,
222                    truncation_result.messages_removed
223                );
224                *msg = ChatCompletionRequestMessage::User(
225                    ChatCompletionRequestUserMessage {
226                        content: notice.into(),
227                        name: Some("system_notice".to_string()),
228                    }
229                    .into(),
230                );
231            }
232
233            tracing::info!(
234                "Compression triggered: removed {} tokens (threshold: {})",
235                crate::context::estimate_messages_tokens(&truncation_result.removed_messages),
236                self.context_manager.compression_token_threshold
237            );
238        }
239
240        // Build tool schemas
241        let tool_schemas = self.tools.tool_schemas();
242        let tools_param = if tool_schemas.is_empty() {
243            None
244        } else {
245            Some(tool_schemas)
246        };
247
248        // Call LLM (streaming)
249        let mut stream = self
250            .llm_client
251            .chat_stream(session.history.clone(), tools_param)
252            .await?;
253
254        // Collect streaming response
255        let mut full_text = String::new();
256        let mut tool_call_chunks: HashMap<usize, ToolCallAccumulator> = HashMap::new();
257
258        while let Some(chunk_result) = stream.next().await {
259            let chunk = chunk_result.map_err(|e| AgentError::LlmError(e.into()))?;
260
261            if let Some(choice) = chunk.choices.first() {
262                // Text content
263                if let Some(content) = &choice.delta.content {
264                    full_text.push_str(content);
265                    let _ = self
266                        .frontend
267                        .on_event(AgentEvent::TextDelta(content.clone()))
268                        .await;
269                }
270
271                // Tool call deltas
272                if let Some(tool_calls) = &choice.delta.tool_calls {
273                    for tc in tool_calls {
274                        let acc = tool_call_chunks
275                            .entry(tc.index as usize)
276                            .or_insert_with(ToolCallAccumulator::new);
277
278                        if let Some(id) = &tc.id {
279                            acc.id = Some(id.clone());
280                        }
281                        if let Some(function) = &tc.function {
282                            if let Some(name) = &function.name {
283                                acc.name = Some(name.clone());
284                            }
285                            if let Some(args) = &function.arguments {
286                                acc.arguments.push_str(args);
287                            }
288                        }
289                    }
290                }
291            }
292        }
293
294        // Assemble complete tool calls from chunks
295        let assembled_tool_calls: Vec<ChatCompletionMessageToolCall> = {
296            let mut indices: Vec<usize> = tool_call_chunks.keys().cloned().collect();
297            indices.sort();
298            indices
299                .into_iter()
300                .filter_map(|idx| tool_call_chunks.remove(&idx)?.into_tool_call())
301                .collect()
302        };
303
304        // Add assistant message to history
305        let assistant_msg = ChatCompletionRequestMessage::Assistant(
306            ChatCompletionRequestAssistantMessage {
307                content: if full_text.is_empty() {
308                    None
309                } else {
310                    Some(full_text.clone().into())
311                },
312                name: None,
313                tool_calls: if assembled_tool_calls.is_empty() {
314                    None
315                } else {
316                    Some(
317                        assembled_tool_calls
318                            .clone()
319                            .into_iter()
320                            .map(ChatCompletionMessageToolCalls::Function)
321                            .collect(),
322                    )
323                },
324                refusal: None,
325                audio: None,
326                #[allow(deprecated)]
327                function_call: None,
328            }
329            .into(),
330        );
331
332        let session = self
333            .sessions
334            .get_mut(session_id)
335            .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
336        session.history.push(assistant_msg);
337        let working_dir = session.working_dir.clone();
338
339        // If no tool calls, turn is complete
340        if assembled_tool_calls.is_empty() {
341            return Ok(false);
342        }
343
344        // Execute each tool call
345        for tc in &assembled_tool_calls {
346            let tc_info = ToolCallInfo {
347                id: tc.id.clone(),
348                name: tc.function.name.clone(),
349                arguments: tc.function.arguments.clone(),
350            };
351
352            // Notify frontend
353            let _ = self
354                .frontend
355                .on_event(AgentEvent::ToolCallRequested {
356                    tool_call_id: tc_info.id.clone(),
357                    name: tc_info.name.clone(),
358                    arguments: tc_info.arguments.clone(),
359                })
360                .await;
361
362            // Check confirmation
363            let approved = if self.tools.requires_confirmation(&tc.function.name) && !self.auto_approve {
364                self.frontend.request_tool_confirmation(&tc_info).await?
365            } else {
366                true
367            };
368
369            // Execute or reject
370            let result = if approved {
371                let args: serde_json::Value = serde_json::from_str(&tc.function.arguments)
372                    .unwrap_or(serde_json::Value::Null);
373
374                let ctx = ToolContext {
375                    working_dir: working_dir.clone(),
376                    session_id: session_id.clone(),
377                    frontend: self.frontend.clone(),
378                };
379
380                self.tools.execute(&tc.function.name, args, &ctx).await
381            } else {
382                ToolResult::error("User rejected this tool call")
383            };
384
385            // Truncate output
386            let truncated_result = ToolResult {
387                content: self.context_manager.truncate_tool_output(&result.content),
388                is_error: result.is_error,
389            };
390
391            // Notify frontend of result
392            let _ = self
393                .frontend
394                .on_event(AgentEvent::ToolCallResult {
395                    tool_call_id: tc.id.clone(),
396                    result: truncated_result.clone(),
397                })
398                .await;
399
400            // Add tool result to history
401            let tool_msg = ChatCompletionRequestMessage::Tool(
402                ChatCompletionRequestToolMessage {
403                    content: truncated_result.content.into(),
404                    tool_call_id: tc.id.clone(),
405                }
406                .into(),
407            );
408
409            let session = self
410                .sessions
411                .get_mut(session_id)
412                .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
413            session.history.push(tool_msg);
414        }
415
416        Ok(true)
417    }
418
419    /// Clear the current session's history (keep system prompt).
420    fn clear_session(&mut self) {
421        if let Some(session) = self.sessions.get_mut(&self.default_session_id) {
422            session.history.truncate(1);
423        }
424    }
425
426    /// Execute a skill-triggered turn: inject skill content, then run the agent loop.
427    ///
428    /// The skill's full content is injected as a temporary system message and removed
429    /// after the turn completes, so it doesn't occupy context in future turns.
430    async fn run_skill_turn(&mut self, skill: &crate::skill::Skill, args: &str) {
431        // Notify frontend
432        let _ = self
433            .frontend
434            .on_event(AgentEvent::SkillTriggered {
435                name: skill.frontmatter.name.clone(),
436                description: skill.frontmatter.description.clone(),
437            })
438            .await;
439
440        let session_id = self.default_session_id.clone();
441
442        // Inject skill content as a system message
443        let skill_message = format!(
444            "## Skill: {}\n\n{}\n\n{}",
445            skill.frontmatter.name,
446            skill.frontmatter.description,
447            skill.content
448        );
449
450        let skill_msg = ChatCompletionRequestMessage::System(
451            ChatCompletionRequestSystemMessage {
452                content: skill_message.into(),
453                name: Some(skill.frontmatter.name.clone()),
454            }
455            .into(),
456        );
457
458        if let Some(session) = self.sessions.get_mut(&session_id) {
459            session.history.push(skill_msg);
460        }
461
462        // Add user message (args or default)
463        let user_content = if args.is_empty() {
464            "(User triggered skill, no additional arguments)".to_string()
465        } else {
466            args.to_string()
467        };
468
469        if let Some(session) = self.sessions.get_mut(&session_id) {
470            session.history.push(ChatCompletionRequestMessage::User(
471                ChatCompletionRequestUserMessage {
472                    content: user_content.into(),
473                    name: None,
474                }
475                .into(),
476            ));
477        }
478
479        // Run the agentic loop
480        let max_iterations = 20;
481        let mut completed = false;
482        for iteration in 0..max_iterations {
483            match self.run_one_step(&session_id).await {
484                Ok(has_tool_calls) => {
485                    if !has_tool_calls {
486                        completed = true;
487                        break;
488                    }
489                    tracing::debug!(
490                        "Skill iteration {}: tool calls executed",
491                        iteration
492                    );
493                }
494                Err(e) => {
495                    let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
496                    break;
497                }
498            }
499        }
500
501        if !completed {
502            let _ = self
503                .frontend
504                .on_event(AgentEvent::Error(AgentError::InternalError(
505                    format!("Max iterations reached ({})", max_iterations),
506                )))
507                .await;
508        }
509
510        let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
511
512        // Remove the injected skill system message to avoid polluting future turns
513        if let Some(session) = self.sessions.get_mut(&session_id) {
514            let skill_name = skill.frontmatter.name.clone();
515            session.history.retain(|msg| {
516                !matches!(
517                    msg,
518                    ChatCompletionRequestMessage::System(s)
519                        if s.name.as_deref() == Some(&skill_name)
520                )
521            });
522        }
523    }
524}
525
526// ============================================================================
527// Helper types
528// ============================================================================
529
530/// Accumulates streaming tool call chunks.
531struct ToolCallAccumulator {
532    id: Option<String>,
533    name: Option<String>,
534    arguments: String,
535}
536
537impl ToolCallAccumulator {
538    fn new() -> Self {
539        Self {
540            id: None,
541            name: None,
542            arguments: String::new(),
543        }
544    }
545
546    /// Convert accumulated chunks into a complete tool call.
547    fn into_tool_call(self) -> Option<ChatCompletionMessageToolCall> {
548        let id = self.id?;
549        let name = self.name?;
550        Some(ChatCompletionMessageToolCall {
551            id,
552            function: FunctionCall {
553                name,
554                arguments: self.arguments,
555            },
556        })
557    }
558}