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 tracing::info!(
164 "Agent::with_history: session_id={}, received {} history messages",
165 session_id,
166 history.len()
167 );
168 for (idx, msg) in history.iter().enumerate() {
169 let role = match msg {
170 ChatCompletionRequestMessage::System(_) => "system",
171 ChatCompletionRequestMessage::User(_) => "user",
172 ChatCompletionRequestMessage::Assistant(_) => "assistant",
173 ChatCompletionRequestMessage::Tool(_) => "tool",
174 ChatCompletionRequestMessage::Developer(_) => "developer",
175 ChatCompletionRequestMessage::Function(_) => "function",
176 };
177 tracing::debug!(" History message {}: role={}", idx, role);
178 }
179
180 let prompt_builder = PromptBuilder::with_working_dir(Some(&working_dir));
181 let context_manager = ContextManager::new(context_window, context_config);
182
183 let tool_refs: Vec<&dyn crate::tool::Tool> = tools.tools();
185 let skill_descs = skills.skill_descriptions();
186 let system_prompt = prompt_builder.build_system_prompt(&tool_refs, &skill_descs, &working_dir);
187
188 let mut session = AgentSession::with_history(
190 session_id.clone(),
191 working_dir,
192 system_prompt,
193 history,
194 );
195
196 tracing::info!(
197 "Agent::with_history: after adding system prompt, session history length = {}",
198 session.history.len()
199 );
200 for (idx, msg) in session.history.iter().enumerate() {
201 let role = match msg {
202 ChatCompletionRequestMessage::System(_) => "system",
203 ChatCompletionRequestMessage::User(_) => "user",
204 ChatCompletionRequestMessage::Assistant(_) => "assistant",
205 ChatCompletionRequestMessage::Tool(_) => "tool",
206 ChatCompletionRequestMessage::Developer(_) => "developer",
207 ChatCompletionRequestMessage::Function(_) => "function",
208 };
209 tracing::debug!(" Session history {}: role={}", idx, role);
210 }
211
212 let truncation_result = context_manager.maybe_truncate(&mut session.history);
214 if truncation_result.rounds_removed > 0 {
215 tracing::info!(
216 "Agent::with_history: truncated {} rounds ({} messages), needs_compression={}",
217 truncation_result.rounds_removed,
218 truncation_result.messages_removed,
219 truncation_result.needs_compression
220 );
221 }
222 tracing::debug!(
223 "Agent::with_history: after truncation, session history length = {}",
224 session.history.len()
225 );
226
227 let mut sessions = HashMap::new();
228 sessions.insert(session_id.clone(), session);
229
230 Self {
231 llm_client,
232 tools,
233 skills,
234 sessions,
235 default_session_id: session_id,
236 context_manager,
237 frontend,
238 auto_approve,
239 extensions,
240 }
241 }
242
243 pub async fn run(mut self, mut message_rx: mpsc::Receiver<FrontendMessage>) {
246 tracing::info!("Agent started, session: {}", self.default_session_id);
247
248 while let Some(msg) = message_rx.recv().await {
249 match msg {
250 FrontendMessage::UserInput { text, attachments } => {
251 if text == "/exit" || text == "/quit" {
252 break;
253 }
254 if text == "/clear" {
255 self.clear_session();
256 let _ = self
257 .frontend
258 .on_event(AgentEvent::TextDelta(
259 "\n[Conversation history cleared]\n".to_string(),
260 ))
261 .await;
262 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
263 continue;
264 }
265
266 if let Some((skill, args)) = self.skills.match_trigger(&text) {
268 let skill = skill.clone();
269 self.run_skill_turn(&skill, &args).await;
270 continue;
271 }
272
273 self.run_turn(&text, attachments).await;
274 }
275 FrontendMessage::Cancel => {
276 tracing::info!("Cancel requested (MVP: no-op)");
277 }
278 FrontendMessage::ConfirmationResponse { .. } => {
279 tracing::warn!("Unexpected ConfirmationResponse outside tool confirmation");
282 }
283 }
284 }
285
286 tracing::info!("Agent stopped");
287 }
288
289 async fn run_turn(&mut self, user_input: &str, attachments: Vec<MediaAttachment>) {
291 let session_id = self.default_session_id.clone();
292
293 let user_message = self.build_user_message(user_input, &attachments).await;
295
296 if let Some(session) = self.sessions.get_mut(&session_id) {
298 session.history.push(user_message);
299 }
300
301 let max_iterations = 20;
303 for iteration in 0..max_iterations {
304 match self.run_one_step(&session_id).await {
305 Ok(has_tool_calls) => {
306 if !has_tool_calls {
307 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
308 return;
309 }
310 tracing::debug!(
311 "Iteration {}: tool calls executed, continuing loop",
312 iteration
313 );
314 }
315 Err(e) => {
316 let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
317 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
318 return;
319 }
320 }
321 }
322
323 let _ = self
325 .frontend
326 .on_event(AgentEvent::Error(AgentError::InternalError(
327 format!("Max iterations reached ({})", max_iterations),
328 )))
329 .await;
330 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
331 }
332
333 async fn run_one_step(&mut self, session_id: &SessionId) -> Result<bool> {
337 let session = self
338 .sessions
339 .get_mut(session_id)
340 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
341
342 let truncation_result = self.context_manager.maybe_truncate(&mut session.history);
344
345 if truncation_result.needs_compression {
347 if let Some(msg) = session.history.get_mut(truncation_result.insert_position) {
350 let notice = format!(
351 "[Omitted {} rounds, {} messages. Context compressed to save space]",
352 truncation_result.rounds_removed,
353 truncation_result.messages_removed
354 );
355 *msg = ChatCompletionRequestMessage::User(
356 ChatCompletionRequestUserMessage {
357 content: notice.into(),
358 name: Some("system_notice".to_string()),
359 }
360 .into(),
361 );
362 }
363
364 tracing::info!(
365 "Compression triggered: removed {} tokens (threshold: {})",
366 crate::context::estimate_messages_tokens(&truncation_result.removed_messages),
367 self.context_manager.compression_token_threshold
368 );
369 }
370
371 let tool_schemas = self.tools.tool_schemas();
373 let tools_param = if tool_schemas.is_empty() {
374 None
375 } else {
376 Some(tool_schemas)
377 };
378
379 let mut stream = self
381 .llm_client
382 .chat_stream(session.history.clone(), tools_param)
383 .await?;
384
385 let mut full_text = String::new();
387 let mut tool_call_chunks: HashMap<usize, ToolCallAccumulator> = HashMap::new();
388
389 while let Some(chunk_result) = stream.next().await {
390 let chunk = chunk_result.map_err(|e| AgentError::LlmError(e.into()))?;
391
392 if let Some(choice) = chunk.choices.first() {
393 if let Some(content) = &choice.delta.content {
395 full_text.push_str(content);
396 let _ = self
397 .frontend
398 .on_event(AgentEvent::TextDelta(content.clone()))
399 .await;
400 }
401
402 if let Some(tool_calls) = &choice.delta.tool_calls {
404 for tc in tool_calls {
405 tracing::debug!(
406 "Received tool call chunk: index={}, id={:?}, function={:?}",
407 tc.index,
408 tc.id,
409 tc.function
410 );
411
412 let acc = tool_call_chunks
413 .entry(tc.index as usize)
414 .or_insert_with(ToolCallAccumulator::new);
415
416 if let Some(id) = &tc.id {
417 if !id.is_empty() {
419 tracing::debug!("Updating tool id: '{}'", id);
420 acc.id = Some(id.clone());
421 }
422 }
423 if let Some(function) = &tc.function {
424 if let Some(name) = &function.name {
425 if !name.is_empty() {
427 tracing::debug!("Tool name chunk: '{}'", name);
428 acc.name = Some(name.clone());
429 }
430 }
431 if let Some(args) = &function.arguments {
432 tracing::debug!("Tool args chunk: '{}'", args);
433 acc.arguments.push_str(args);
434 }
435 }
436
437 tracing::debug!("Accumulator state after chunk: {:?}", acc);
438 }
439 }
440 }
441 }
442
443 let assembled_tool_calls: Vec<ChatCompletionMessageToolCall> = {
445 let mut indices: Vec<usize> = tool_call_chunks.keys().cloned().collect();
446 indices.sort();
447 indices
448 .into_iter()
449 .filter_map(|idx| tool_call_chunks.remove(&idx)?.into_tool_call())
450 .collect()
451 };
452
453 tracing::debug!("Assembled {} tool call(s)", assembled_tool_calls.len());
454 for (i, tc) in assembled_tool_calls.iter().enumerate() {
455 tracing::debug!(
456 "Tool call [{}]: id='{}', name='{}', arguments='{}'",
457 i,
458 tc.id,
459 tc.function.name,
460 tc.function.arguments
461 );
462 }
463
464 let assistant_msg = ChatCompletionRequestMessage::Assistant(
466 ChatCompletionRequestAssistantMessage {
467 content: if full_text.is_empty() {
468 None
469 } else {
470 Some(full_text.clone().into())
471 },
472 name: None,
473 tool_calls: if assembled_tool_calls.is_empty() {
474 None
475 } else {
476 Some(
477 assembled_tool_calls
478 .clone()
479 .into_iter()
480 .map(ChatCompletionMessageToolCalls::Function)
481 .collect(),
482 )
483 },
484 refusal: None,
485 audio: None,
486 #[allow(deprecated)]
487 function_call: None,
488 }
489 .into(),
490 );
491
492 let session = self
493 .sessions
494 .get_mut(session_id)
495 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
496 session.history.push(assistant_msg);
497 let working_dir = session.working_dir.clone();
498
499 if assembled_tool_calls.is_empty() {
501 return Ok(false);
502 }
503
504 for tc in &assembled_tool_calls {
506 tracing::info!(
507 "About to execute tool: id='{}', name='{}'",
508 tc.id,
509 tc.function.name
510 );
511
512 let tc_info = ToolCallInfo {
513 id: tc.id.clone(),
514 name: tc.function.name.clone(),
515 arguments: tc.function.arguments.clone(),
516 };
517
518 let _ = self
520 .frontend
521 .on_event(AgentEvent::ToolCallRequested {
522 tool_call_id: tc_info.id.clone(),
523 name: tc_info.name.clone(),
524 arguments: tc_info.arguments.clone(),
525 })
526 .await;
527
528 let approved = if self.tools.requires_confirmation(&tc.function.name) && !self.auto_approve {
530 self.frontend.request_tool_confirmation(&tc_info).await?
531 } else {
532 true
533 };
534
535 let result = if approved {
537 let args: serde_json::Value = serde_json::from_str(&tc.function.arguments)
538 .unwrap_or(serde_json::Value::Null);
539
540 let ctx = ToolContext {
541 working_dir: working_dir.clone(),
542 session_id: session_id.clone(),
543 frontend: self.frontend.clone(),
544 extensions: self.extensions.clone(),
545 };
546
547 self.tools.execute(&tc.function.name, args, &ctx).await
548 } else {
549 ToolResult::error("User rejected this tool call")
550 };
551
552 let truncated_result = ToolResult {
554 content: self.context_manager.truncate_tool_output(&result.content),
555 is_error: result.is_error,
556 };
557
558 let _ = self
560 .frontend
561 .on_event(AgentEvent::ToolCallResult {
562 tool_call_id: tc.id.clone(),
563 result: truncated_result.clone(),
564 })
565 .await;
566
567 let tool_msg = ChatCompletionRequestMessage::Tool(
569 ChatCompletionRequestToolMessage {
570 content: truncated_result.content.into(),
571 tool_call_id: tc.id.clone(),
572 }
573 .into(),
574 );
575
576 let session = self
577 .sessions
578 .get_mut(session_id)
579 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
580 session.history.push(tool_msg);
581 }
582
583 Ok(true)
584 }
585
586 fn clear_session(&mut self) {
588 if let Some(session) = self.sessions.get_mut(&self.default_session_id) {
589 session.history.truncate(1);
590 }
591 }
592
593 async fn build_user_message(
595 &self,
596 text: &str,
597 attachments: &[MediaAttachment],
598 ) -> ChatCompletionRequestMessage {
599 if self.llm_client.supports_images()
601 && !attachments.is_empty()
602 && attachments.iter().any(|a| a.is_image())
603 {
604 self.build_multimodal_message(text, attachments)
605 .await
606 } else {
607 let mut full_text = text.to_string();
609 for attachment in attachments {
610 full_text = format!("{}\n{}", full_text, attachment.describe());
611 }
612 ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
613 content: full_text.into(),
614 name: None,
615 })
616 }
617 }
618
619 async fn build_multimodal_message(
621 &self,
622 text: &str,
623 attachments: &[MediaAttachment],
624 ) -> ChatCompletionRequestMessage {
625 let mut parts = vec![ChatCompletionRequestUserMessageContentPart::Text(
626 ChatCompletionRequestMessageContentPartText {
627 text: text.to_string(),
628 },
629 )];
630
631 for attachment in attachments {
633 if attachment.is_image() {
634 match media::download_and_encode_base64(
636 &attachment.url,
637 &attachment.content_type,
638 )
639 .await
640 {
641 Ok(base64_url) => {
642 parts.push(ChatCompletionRequestUserMessageContentPart::ImageUrl(
643 ChatCompletionRequestMessageContentPartImage {
644 image_url: ImageUrl {
645 url: base64_url,
646 detail: None,
647 },
648 },
649 ));
650 }
651 Err(e) => {
652 tracing::warn!("Failed to encode image: {}", e);
653 let desc = attachment.describe();
655 let current_text = match &mut parts[0] {
656 ChatCompletionRequestUserMessageContentPart::Text(t) => &mut t.text,
657 _ => unreachable!(),
658 };
659 *current_text = format!("{}\n{}", current_text, desc);
660 }
661 }
662 } else {
663 let desc = attachment.describe();
665 let current_text = match &mut parts[0] {
666 ChatCompletionRequestUserMessageContentPart::Text(t) => &mut t.text,
667 _ => unreachable!(),
668 };
669 *current_text = format!("{}\n{}", current_text, desc);
670 }
671 }
672
673 ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
674 content: ChatCompletionRequestUserMessageContent::Array(parts),
675 name: None,
676 })
677 }
678
679 async fn run_skill_turn(&mut self, skill: &crate::skill::Skill, args: &str) {
684 let _ = self
686 .frontend
687 .on_event(AgentEvent::SkillTriggered {
688 name: skill.frontmatter.name.clone(),
689 description: skill.frontmatter.description.clone(),
690 })
691 .await;
692
693 let session_id = self.default_session_id.clone();
694
695 let skill_message = format!(
697 "## Skill: {}\n\n{}\n\n{}",
698 skill.frontmatter.name,
699 skill.frontmatter.description,
700 skill.content
701 );
702
703 let skill_msg = ChatCompletionRequestMessage::System(
704 ChatCompletionRequestSystemMessage {
705 content: skill_message.into(),
706 name: Some(skill.frontmatter.name.clone()),
707 }
708 .into(),
709 );
710
711 if let Some(session) = self.sessions.get_mut(&session_id) {
712 session.history.push(skill_msg);
713 }
714
715 let user_content = if args.is_empty() {
717 "(User triggered skill, no additional arguments)".to_string()
718 } else {
719 args.to_string()
720 };
721
722 if let Some(session) = self.sessions.get_mut(&session_id) {
723 session.history.push(ChatCompletionRequestMessage::User(
724 ChatCompletionRequestUserMessage {
725 content: user_content.into(),
726 name: None,
727 }
728 .into(),
729 ));
730 }
731
732 let max_iterations = 20;
734 let mut completed = false;
735 for iteration in 0..max_iterations {
736 match self.run_one_step(&session_id).await {
737 Ok(has_tool_calls) => {
738 if !has_tool_calls {
739 completed = true;
740 break;
741 }
742 tracing::debug!(
743 "Skill iteration {}: tool calls executed",
744 iteration
745 );
746 }
747 Err(e) => {
748 let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
749 break;
750 }
751 }
752 }
753
754 if !completed {
755 let _ = self
756 .frontend
757 .on_event(AgentEvent::Error(AgentError::InternalError(
758 format!("Max iterations reached ({})", max_iterations),
759 )))
760 .await;
761 }
762
763 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
764
765 if let Some(session) = self.sessions.get_mut(&session_id) {
767 let skill_name = skill.frontmatter.name.clone();
768 session.history.retain(|msg| {
769 !matches!(
770 msg,
771 ChatCompletionRequestMessage::System(s)
772 if s.name.as_deref() == Some(&skill_name)
773 )
774 });
775 }
776 }
777}
778
779#[derive(Debug)]
785struct ToolCallAccumulator {
786 id: Option<String>,
787 name: Option<String>,
788 arguments: String,
789}
790
791impl ToolCallAccumulator {
792 fn new() -> Self {
793 Self {
794 id: None,
795 name: None,
796 arguments: String::new(),
797 }
798 }
799
800 fn into_tool_call(self) -> Option<ChatCompletionMessageToolCall> {
802 tracing::debug!("Converting accumulator to tool call: {:?}", self);
803
804 let id = self.id?;
805 let name = self.name?;
806
807 tracing::debug!("Tool call assembled: id='{}', name='{}', args='{}'", id, name, self.arguments);
808
809 Some(ChatCompletionMessageToolCall {
810 id,
811 function: FunctionCall {
812 name,
813 arguments: self.arguments,
814 },
815 })
816 }
817}