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 pub fn with_history(
64 session_id: SessionId,
65 working_dir: PathBuf,
66 system_prompt: String,
67 history: Vec<ChatCompletionRequestMessage>,
68 ) -> Self {
69 let system_msg = ChatCompletionRequestMessage::System(
71 ChatCompletionRequestSystemMessage {
72 content: system_prompt.into(),
73 name: None,
74 }
75 .into(),
76 );
77
78 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
90pub 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 extensions: HashMap<String, Arc<dyn Any + Send + Sync>>,
106}
107
108impl Agent {
109 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 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 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 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 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 let mut session = AgentSession::with_history(
173 session_id.clone(),
174 working_dir,
175 system_prompt,
176 history,
177 );
178
179 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 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 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 tracing::warn!("Unexpected ConfirmationResponse outside tool confirmation");
237 }
238 }
239 }
240
241 tracing::info!("Agent stopped");
242 }
243
244 async fn run_turn(&mut self, user_input: &str, attachments: Vec<MediaAttachment>) {
246 let session_id = self.default_session_id.clone();
247
248 let user_message = self.build_user_message(user_input, &attachments).await;
250
251 if let Some(session) = self.sessions.get_mut(&session_id) {
253 session.history.push(user_message);
254 }
255
256 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 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 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 let truncation_result = self.context_manager.maybe_truncate(&mut session.history);
299
300 if truncation_result.needs_compression {
302 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 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 let mut stream = self
336 .llm_client
337 .chat_stream(session.history.clone(), tools_param)
338 .await?;
339
340 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 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 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 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 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 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 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 assembled_tool_calls.is_empty() {
456 return Ok(false);
457 }
458
459 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 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 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 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 let truncated_result = ToolResult {
509 content: self.context_manager.truncate_tool_output(&result.content),
510 is_error: result.is_error,
511 };
512
513 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 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 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 async fn build_user_message(
550 &self,
551 text: &str,
552 attachments: &[MediaAttachment],
553 ) -> ChatCompletionRequestMessage {
554 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 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 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 for attachment in attachments {
588 if attachment.is_image() {
589 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 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 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 async fn run_skill_turn(&mut self, skill: &crate::skill::Skill, args: &str) {
639 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 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 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 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 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#[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 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}