1use 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
14use 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
34pub 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
63pub 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 extensions: HashMap<String, Arc<dyn Any + Send + Sync>>,
79}
80
81impl Agent {
82 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 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 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 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 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 tracing::warn!("Unexpected ConfirmationResponse outside tool confirmation");
161 }
162 }
163 }
164
165 tracing::info!("Agent stopped");
166 }
167
168 async fn run_turn(&mut self, user_input: &str, attachments: Vec<MediaAttachment>) {
170 let session_id = self.default_session_id.clone();
171
172 let user_message = self.build_user_message(user_input, &attachments).await;
174
175 if let Some(session) = self.sessions.get_mut(&session_id) {
177 session.history.push(user_message);
178 }
179
180 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 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 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 let truncation_result = self.context_manager.maybe_truncate(&mut session.history);
223
224 if truncation_result.needs_compression {
226 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 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 let mut stream = self
260 .llm_client
261 .chat_stream(session.history.clone(), tools_param)
262 .await?;
263
264 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 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 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 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 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 assembled_tool_calls.is_empty() {
351 return Ok(false);
352 }
353
354 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 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 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 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 let truncated_result = ToolResult {
398 content: self.context_manager.truncate_tool_output(&result.content),
399 is_error: result.is_error,
400 };
401
402 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 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 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 async fn build_user_message(
439 &self,
440 text: &str,
441 attachments: &[MediaAttachment],
442 ) -> ChatCompletionRequestMessage {
443 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 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 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 for attachment in attachments {
477 if attachment.is_image() {
478 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 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 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 async fn run_skill_turn(&mut self, skill: &crate::skill::Skill, args: &str) {
528 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 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 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 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 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
623struct 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 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}