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 tracing::debug!(
285 "Received tool call chunk: index={}, id={:?}, function={:?}",
286 tc.index,
287 tc.id,
288 tc.function
289 );
290
291 let acc = tool_call_chunks
292 .entry(tc.index as usize)
293 .or_insert_with(ToolCallAccumulator::new);
294
295 if let Some(id) = &tc.id {
296 if !id.is_empty() {
298 tracing::debug!("Updating tool id: '{}'", id);
299 acc.id = Some(id.clone());
300 }
301 }
302 if let Some(function) = &tc.function {
303 if let Some(name) = &function.name {
304 if !name.is_empty() {
306 tracing::debug!("Tool name chunk: '{}'", name);
307 acc.name = Some(name.clone());
308 }
309 }
310 if let Some(args) = &function.arguments {
311 tracing::debug!("Tool args chunk: '{}'", args);
312 acc.arguments.push_str(args);
313 }
314 }
315
316 tracing::debug!("Accumulator state after chunk: {:?}", acc);
317 }
318 }
319 }
320 }
321
322 let assembled_tool_calls: Vec<ChatCompletionMessageToolCall> = {
324 let mut indices: Vec<usize> = tool_call_chunks.keys().cloned().collect();
325 indices.sort();
326 indices
327 .into_iter()
328 .filter_map(|idx| tool_call_chunks.remove(&idx)?.into_tool_call())
329 .collect()
330 };
331
332 tracing::debug!("Assembled {} tool call(s)", assembled_tool_calls.len());
333 for (i, tc) in assembled_tool_calls.iter().enumerate() {
334 tracing::debug!(
335 "Tool call [{}]: id='{}', name='{}', arguments='{}'",
336 i,
337 tc.id,
338 tc.function.name,
339 tc.function.arguments
340 );
341 }
342
343 let assistant_msg = ChatCompletionRequestMessage::Assistant(
345 ChatCompletionRequestAssistantMessage {
346 content: if full_text.is_empty() {
347 None
348 } else {
349 Some(full_text.clone().into())
350 },
351 name: None,
352 tool_calls: if assembled_tool_calls.is_empty() {
353 None
354 } else {
355 Some(
356 assembled_tool_calls
357 .clone()
358 .into_iter()
359 .map(ChatCompletionMessageToolCalls::Function)
360 .collect(),
361 )
362 },
363 refusal: None,
364 audio: None,
365 #[allow(deprecated)]
366 function_call: None,
367 }
368 .into(),
369 );
370
371 let session = self
372 .sessions
373 .get_mut(session_id)
374 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
375 session.history.push(assistant_msg);
376 let working_dir = session.working_dir.clone();
377
378 if assembled_tool_calls.is_empty() {
380 return Ok(false);
381 }
382
383 for tc in &assembled_tool_calls {
385 tracing::info!(
386 "About to execute tool: id='{}', name='{}'",
387 tc.id,
388 tc.function.name
389 );
390
391 let tc_info = ToolCallInfo {
392 id: tc.id.clone(),
393 name: tc.function.name.clone(),
394 arguments: tc.function.arguments.clone(),
395 };
396
397 let _ = self
399 .frontend
400 .on_event(AgentEvent::ToolCallRequested {
401 tool_call_id: tc_info.id.clone(),
402 name: tc_info.name.clone(),
403 arguments: tc_info.arguments.clone(),
404 })
405 .await;
406
407 let approved = if self.tools.requires_confirmation(&tc.function.name) && !self.auto_approve {
409 self.frontend.request_tool_confirmation(&tc_info).await?
410 } else {
411 true
412 };
413
414 let result = if approved {
416 let args: serde_json::Value = serde_json::from_str(&tc.function.arguments)
417 .unwrap_or(serde_json::Value::Null);
418
419 let ctx = ToolContext {
420 working_dir: working_dir.clone(),
421 session_id: session_id.clone(),
422 frontend: self.frontend.clone(),
423 extensions: self.extensions.clone(),
424 };
425
426 self.tools.execute(&tc.function.name, args, &ctx).await
427 } else {
428 ToolResult::error("User rejected this tool call")
429 };
430
431 let truncated_result = ToolResult {
433 content: self.context_manager.truncate_tool_output(&result.content),
434 is_error: result.is_error,
435 };
436
437 let _ = self
439 .frontend
440 .on_event(AgentEvent::ToolCallResult {
441 tool_call_id: tc.id.clone(),
442 result: truncated_result.clone(),
443 })
444 .await;
445
446 let tool_msg = ChatCompletionRequestMessage::Tool(
448 ChatCompletionRequestToolMessage {
449 content: truncated_result.content.into(),
450 tool_call_id: tc.id.clone(),
451 }
452 .into(),
453 );
454
455 let session = self
456 .sessions
457 .get_mut(session_id)
458 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
459 session.history.push(tool_msg);
460 }
461
462 Ok(true)
463 }
464
465 fn clear_session(&mut self) {
467 if let Some(session) = self.sessions.get_mut(&self.default_session_id) {
468 session.history.truncate(1);
469 }
470 }
471
472 async fn build_user_message(
474 &self,
475 text: &str,
476 attachments: &[MediaAttachment],
477 ) -> ChatCompletionRequestMessage {
478 if self.llm_client.supports_images()
480 && !attachments.is_empty()
481 && attachments.iter().any(|a| a.is_image())
482 {
483 self.build_multimodal_message(text, attachments)
484 .await
485 } else {
486 let mut full_text = text.to_string();
488 for attachment in attachments {
489 full_text = format!("{}\n{}", full_text, attachment.describe());
490 }
491 ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
492 content: full_text.into(),
493 name: None,
494 })
495 }
496 }
497
498 async fn build_multimodal_message(
500 &self,
501 text: &str,
502 attachments: &[MediaAttachment],
503 ) -> ChatCompletionRequestMessage {
504 let mut parts = vec![ChatCompletionRequestUserMessageContentPart::Text(
505 ChatCompletionRequestMessageContentPartText {
506 text: text.to_string(),
507 },
508 )];
509
510 for attachment in attachments {
512 if attachment.is_image() {
513 match media::download_and_encode_base64(
515 &attachment.url,
516 &attachment.content_type,
517 )
518 .await
519 {
520 Ok(base64_url) => {
521 parts.push(ChatCompletionRequestUserMessageContentPart::ImageUrl(
522 ChatCompletionRequestMessageContentPartImage {
523 image_url: ImageUrl {
524 url: base64_url,
525 detail: None,
526 },
527 },
528 ));
529 }
530 Err(e) => {
531 tracing::warn!("Failed to encode image: {}", e);
532 let desc = attachment.describe();
534 let current_text = match &mut parts[0] {
535 ChatCompletionRequestUserMessageContentPart::Text(t) => &mut t.text,
536 _ => unreachable!(),
537 };
538 *current_text = format!("{}\n{}", current_text, desc);
539 }
540 }
541 } else {
542 let desc = attachment.describe();
544 let current_text = match &mut parts[0] {
545 ChatCompletionRequestUserMessageContentPart::Text(t) => &mut t.text,
546 _ => unreachable!(),
547 };
548 *current_text = format!("{}\n{}", current_text, desc);
549 }
550 }
551
552 ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
553 content: ChatCompletionRequestUserMessageContent::Array(parts),
554 name: None,
555 })
556 }
557
558 async fn run_skill_turn(&mut self, skill: &crate::skill::Skill, args: &str) {
563 let _ = self
565 .frontend
566 .on_event(AgentEvent::SkillTriggered {
567 name: skill.frontmatter.name.clone(),
568 description: skill.frontmatter.description.clone(),
569 })
570 .await;
571
572 let session_id = self.default_session_id.clone();
573
574 let skill_message = format!(
576 "## Skill: {}\n\n{}\n\n{}",
577 skill.frontmatter.name,
578 skill.frontmatter.description,
579 skill.content
580 );
581
582 let skill_msg = ChatCompletionRequestMessage::System(
583 ChatCompletionRequestSystemMessage {
584 content: skill_message.into(),
585 name: Some(skill.frontmatter.name.clone()),
586 }
587 .into(),
588 );
589
590 if let Some(session) = self.sessions.get_mut(&session_id) {
591 session.history.push(skill_msg);
592 }
593
594 let user_content = if args.is_empty() {
596 "(User triggered skill, no additional arguments)".to_string()
597 } else {
598 args.to_string()
599 };
600
601 if let Some(session) = self.sessions.get_mut(&session_id) {
602 session.history.push(ChatCompletionRequestMessage::User(
603 ChatCompletionRequestUserMessage {
604 content: user_content.into(),
605 name: None,
606 }
607 .into(),
608 ));
609 }
610
611 let max_iterations = 20;
613 let mut completed = false;
614 for iteration in 0..max_iterations {
615 match self.run_one_step(&session_id).await {
616 Ok(has_tool_calls) => {
617 if !has_tool_calls {
618 completed = true;
619 break;
620 }
621 tracing::debug!(
622 "Skill iteration {}: tool calls executed",
623 iteration
624 );
625 }
626 Err(e) => {
627 let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
628 break;
629 }
630 }
631 }
632
633 if !completed {
634 let _ = self
635 .frontend
636 .on_event(AgentEvent::Error(AgentError::InternalError(
637 format!("Max iterations reached ({})", max_iterations),
638 )))
639 .await;
640 }
641
642 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
643
644 if let Some(session) = self.sessions.get_mut(&session_id) {
646 let skill_name = skill.frontmatter.name.clone();
647 session.history.retain(|msg| {
648 !matches!(
649 msg,
650 ChatCompletionRequestMessage::System(s)
651 if s.name.as_deref() == Some(&skill_name)
652 )
653 });
654 }
655 }
656}
657
658#[derive(Debug)]
664struct ToolCallAccumulator {
665 id: Option<String>,
666 name: Option<String>,
667 arguments: String,
668}
669
670impl ToolCallAccumulator {
671 fn new() -> Self {
672 Self {
673 id: None,
674 name: None,
675 arguments: String::new(),
676 }
677 }
678
679 fn into_tool_call(self) -> Option<ChatCompletionMessageToolCall> {
681 tracing::debug!("Converting accumulator to tool call: {:?}", self);
682
683 let id = self.id?;
684 let name = self.name?;
685
686 tracing::debug!("Tool call assembled: id='{}', name='{}', args='{}'", id, name, self.arguments);
687
688 Some(ChatCompletionMessageToolCall {
689 id,
690 function: FunctionCall {
691 name,
692 arguments: self.arguments,
693 },
694 })
695 }
696}