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_util::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, TruncationAction, TruncationResult};
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::async_runner::{AsyncTaskDone, AsyncTaskRunner};
33use crate::tool::task_registry::{AsyncTaskRecord, AsyncTaskStatus, TaskRegistry};
34use crate::tool::{ToolCallInfo, ToolContext, ToolImage, ToolRegistry, ToolResult};
35use tokio_util::sync::CancellationToken;
36
37pub struct AgentSession {
43 pub session_id: SessionId,
44 pub history: Vec<ChatCompletionRequestMessage>,
45 pub working_dir: PathBuf,
46 pub last_known_prompt_tokens: Option<u32>,
52 pub snapshot_message_count: usize,
56}
57
58impl AgentSession {
59 fn new(session_id: SessionId, working_dir: PathBuf, system_prompt: String) -> Self {
60 let system_msg = ChatCompletionRequestMessage::System(
61 ChatCompletionRequestSystemMessage {
62 content: system_prompt.into(),
63 name: None,
64 }
65 .into(),
66 );
67
68 Self {
69 session_id,
70 history: vec![system_msg],
71 working_dir,
72 last_known_prompt_tokens: None,
73 snapshot_message_count: 0,
74 }
75 }
76
77 pub fn with_history(
79 session_id: SessionId,
80 working_dir: PathBuf,
81 system_prompt: String,
82 history: Vec<ChatCompletionRequestMessage>,
83 ) -> Self {
84 let system_msg = ChatCompletionRequestMessage::System(
86 ChatCompletionRequestSystemMessage {
87 content: system_prompt.into(),
88 name: None,
89 }
90 .into(),
91 );
92
93 let mut full_history = vec![system_msg];
95 full_history.extend(history);
96
97 Self {
98 session_id,
99 history: full_history,
100 working_dir,
101 last_known_prompt_tokens: None,
102 snapshot_message_count: 0,
103 }
104 }
105}
106
107pub struct Agent {
113 llm_client: Arc<LlmClient>,
114 tools: Arc<ToolRegistry>,
115 skills: Arc<SkillRegistry>,
116 sessions: HashMap<SessionId, AgentSession>,
117 default_session_id: SessionId,
118 context_manager: ContextManager,
119 frontend: Arc<dyn Frontend>,
120 auto_approve: bool,
121 extensions: HashMap<String, Arc<dyn Any + Send + Sync>>,
123 pending_truncation: Option<(SessionId, crate::context::TruncationResult)>,
125 async_runner: AsyncTaskRunner,
128 done_rx: Option<mpsc::Receiver<AsyncTaskDone>>,
131 pending_tasks: HashMap<String, PendingTask>,
133 task_registry: TaskRegistry,
136}
137
138struct PendingTask {
140 cancel: CancellationToken,
141 tool_name: String,
142}
143
144impl Agent {
145 pub fn new(
147 llm_client: Arc<LlmClient>,
148 tools: Arc<ToolRegistry>,
149 skills: Arc<SkillRegistry>,
150 frontend: Arc<dyn Frontend>,
151 context_config: Option<&ContextConfig>,
152 context_window: Option<u64>,
153 working_dir: PathBuf,
154 auto_approve: bool,
155 extensions: HashMap<String, Arc<dyn Any + Send + Sync>>,
156 ) -> Self {
157 let prompt_builder = PromptBuilder::with_working_dir(Some(&working_dir));
158 let context_manager = ContextManager::new(context_window, context_config);
159
160 let skill_descs = skills.skill_descriptions();
163 let system_prompt = prompt_builder.build_system_prompt(&skill_descs, &working_dir);
164
165 let session_id = new_session_id();
167 let session = AgentSession::new(session_id.clone(), working_dir, system_prompt);
168
169 let mut sessions = HashMap::new();
170 sessions.insert(session_id.clone(), session);
171
172 let (done_tx, done_rx) = mpsc::channel::<AsyncTaskDone>(32);
173 let async_runner = AsyncTaskRunner::new(done_tx);
174 let task_registry = TaskRegistry::new();
175
176 Self {
177 llm_client,
178 tools,
179 skills,
180 sessions,
181 default_session_id: session_id,
182 context_manager,
183 frontend,
184 auto_approve,
185 extensions,
186 pending_truncation: None,
187 async_runner,
188 done_rx: Some(done_rx),
189 pending_tasks: HashMap::new(),
190 task_registry,
191 }
192 }
193
194 pub fn with_history(
196 llm_client: Arc<LlmClient>,
197 tools: Arc<ToolRegistry>,
198 skills: Arc<SkillRegistry>,
199 frontend: Arc<dyn Frontend>,
200 context_config: Option<&ContextConfig>,
201 context_window: Option<u64>,
202 working_dir: PathBuf,
203 auto_approve: bool,
204 extensions: HashMap<String, Arc<dyn Any + Send + Sync>>,
205 session_id: SessionId,
206 history: Vec<ChatCompletionRequestMessage>,
207 ) -> Self {
208 tracing::info!(
209 "Agent::with_history: session_id={}, received {} history messages",
210 session_id,
211 history.len()
212 );
213 let prompt_builder = PromptBuilder::with_working_dir(Some(&working_dir));
214 let context_manager = ContextManager::new(context_window, context_config);
215
216 let skill_descs = skills.skill_descriptions();
219 let system_prompt = prompt_builder.build_system_prompt(&skill_descs, &working_dir);
220
221 let mut session = AgentSession::with_history(
223 session_id.clone(),
224 working_dir,
225 system_prompt,
226 history,
227 );
228
229 tracing::debug!(
230 "Agent::with_history: after adding system prompt, session history length = {}",
231 session.history.len()
232 );
233 let supports_images = llm_client.supports_images();
236 sanitize_history_for_model(&mut session.history, supports_images);
237 let truncation_result = context_manager.maybe_truncate(
239 &mut session.history,
240 session.last_known_prompt_tokens,
241 session.snapshot_message_count,
242 );
243 if truncation_result.rounds_removed > 0 {
244 tracing::info!(
245 "Agent::with_history: truncated {} rounds ({} messages), needs_compression={}",
246 truncation_result.rounds_removed,
247 truncation_result.messages_removed,
248 truncation_result.needs_compression
249 );
250 }
251 tracing::debug!(
252 "Agent::with_history: after truncation, session history length = {}",
253 session.history.len()
254 );
255
256 let pending_truncation = if truncation_result.needs_compression {
257 Some((session_id.clone(), truncation_result))
258 } else {
259 None
260 };
261
262 let mut sessions = HashMap::new();
263 sessions.insert(session_id.clone(), session);
264
265 let (done_tx, done_rx) = mpsc::channel::<AsyncTaskDone>(32);
266 let async_runner = AsyncTaskRunner::new(done_tx);
267 let task_registry = TaskRegistry::new();
268
269 Self {
270 llm_client,
271 tools,
272 skills,
273 sessions,
274 default_session_id: session_id,
275 context_manager,
276 frontend,
277 auto_approve,
278 extensions,
279 pending_truncation,
280 async_runner,
281 done_rx: Some(done_rx),
282 pending_tasks: HashMap::new(),
283 task_registry,
284 }
285 }
286
287 pub async fn run(mut self, mut message_rx: mpsc::Receiver<FrontendMessage>) {
290 tracing::info!("Agent started, session: {}", self.default_session_id);
291
292 if self.pending_truncation.is_some() {
295 tracing::info!("=== Starting pending compression processing ===");
296 let session_id = self.default_session_id.clone();
297 let mut iterations = 0;
298 const MAX_COMPRESSION_ITERATIONS: usize = 20;
299
300 loop {
301 let pending = self.pending_truncation.take();
303 let result = match pending {
304 Some((_, r)) => r,
305 None => break,
306 };
307
308 iterations += 1;
309 if iterations > MAX_COMPRESSION_ITERATIONS {
310 tracing::warn!("Reached max compression iterations ({}), stopping", MAX_COMPRESSION_ITERATIONS);
311 break;
312 }
313
314 tracing::info!("Compression iteration {}: action={:?}, removed_rounds={}, removed_msgs={}",
315 iterations, result.action, result.rounds_removed, result.messages_removed);
316
317 if let Some(session) = self.sessions.get_mut(&session_id) {
319 apply_compression_result(&self.llm_client, &mut session.history, &result).await;
320 session.last_known_prompt_tokens = None;
322 session.snapshot_message_count = 0;
323 }
324
325 let needs_more = if let Some(session) = self.sessions.get(&session_id) {
327 let estimated = self.context_manager.estimate_context_tokens(
328 &session.history,
329 session.last_known_prompt_tokens,
330 session.snapshot_message_count,
331 );
332 estimated > self.context_manager.truncation_threshold()
333 } else {
334 false
335 };
336
337 if !needs_more {
338 tracing::info!("Context below threshold after {} compression iterations", iterations);
339 break;
340 }
341
342 if let Some(session) = self.sessions.get_mut(&session_id) {
344 let next_result = self.context_manager.maybe_truncate(
345 &mut session.history,
346 session.last_known_prompt_tokens,
347 session.snapshot_message_count,
348 );
349 if next_result.needs_compression {
350 self.pending_truncation = Some((session_id.clone(), next_result));
351 } else if next_result.messages_removed > 0 {
352 tracing::info!("Truncation without compression: {} messages removed", next_result.messages_removed);
354 self.pending_truncation = Some((session_id.clone(), next_result));
356 } else {
357 break;
358 }
359 }
360 }
361
362 tracing::info!("=== Compression processing finished ({} iterations) ===", iterations);
363 } else {
364 tracing::debug!("No pending compression needed");
365 }
366
367 let mut done_rx = self
371 .done_rx
372 .take()
373 .expect("done_rx is consumed exactly once in run()");
374
375 loop {
376 tokio::select! {
377 msg = message_rx.recv() => {
378 let Some(msg) = msg else { break; };
379 match msg {
380 FrontendMessage::UserInput { text, attachments } => {
381 if text == "/exit" || text == "/quit" {
382 break;
383 }
384 if text == "/clear" {
385 self.clear_session();
386 let _ = self
387 .frontend
388 .on_event(AgentEvent::TextDelta(
389 "\n[Conversation history cleared]\n".to_string(),
390 ))
391 .await;
392 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
393 continue;
394 }
395
396 if let Some((skill, args)) = self.skills.match_trigger(&text) {
398 let skill = skill.clone();
399 self.run_skill_turn(&skill, &args).await;
400 continue;
401 }
402
403 self.run_turn(&text, attachments).await;
404 }
405 FrontendMessage::Cancel => {
406 self.handle_cancel_all().await;
408 }
409 FrontendMessage::CancelTask { task_id } => {
410 self.handle_cancel_task(&task_id).await;
411 }
412 FrontendMessage::ConfirmationResponse { .. } => {
413 tracing::warn!("Unexpected ConfirmationResponse outside tool confirmation");
416 }
417 }
418 }
419 done = done_rx.recv() => {
420 let Some(done) = done else { break; };
421 self.handle_async_done(done).await;
422 }
423 }
424 }
425
426 if !self.pending_tasks.is_empty() {
432 let remaining = self.pending_tasks.len();
433 tracing::warn!(
434 "[async] Agent exiting with {} pending task(s), cancelling and draining...",
435 remaining
436 );
437 for (_, pending) in self.pending_tasks.drain() {
439 pending.cancel.cancel();
440 }
441 let drain_deadline = tokio::time::Instant::now()
444 + tokio::time::Duration::from_secs(5);
445 while tokio::time::Instant::now() < drain_deadline {
446 match tokio::time::timeout(
447 tokio::time::Duration::from_millis(500),
448 done_rx.recv(),
449 )
450 .await
451 {
452 Ok(Some(done)) => {
453 tracing::info!(
454 "[async] drained result after shutdown: task_id={}, tool={}, cancelled={}",
455 done.task_id, done.tool_name, done.cancelled
456 );
457 self.handle_async_done(done).await;
458 }
459 Ok(None) => {
460 tracing::debug!("[async] done_tx closed during drain");
462 break;
463 }
464 Err(_) => {
465 }
467 }
468 }
469 tracing::info!("[async] drain phase complete");
470 }
471
472 tracing::info!("Agent stopped");
473 }
474
475 async fn run_turn(&mut self, user_input: &str, attachments: Vec<MediaAttachment>) {
477 let session_id = self.default_session_id.clone();
478
479 let user_message = self.build_user_message(user_input, &attachments).await;
481
482 if let Some(session) = self.sessions.get_mut(&session_id) {
484 session.history.push(user_message);
485 }
486
487 self.run_agent_loop(&session_id).await;
489 }
490
491 async fn run_agent_loop(&mut self, session_id: &SessionId) {
495 let max_tool_calls = self.context_manager.max_tool_calls_per_turn;
496 let max_iterations = 20;
497 let mut total_tool_calls = 0usize;
498 for iteration in 0..max_iterations {
499 match self.run_one_step(session_id).await {
500 Ok(0) => {
501 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
502 return;
503 }
504 Ok(tool_call_count) => {
505 total_tool_calls += tool_call_count;
506
507 if total_tool_calls >= max_tool_calls {
509 tracing::warn!(
510 "Tool call limit reached: {} >= {} (max_tool_calls_per_turn), forcing turn completion",
511 total_tool_calls,
512 max_tool_calls
513 );
514 let _ = self
515 .frontend
516 .on_event(AgentEvent::TextDelta(
517 format!(
518 "\n\n[Tool call limit reached ({} calls). Please summarize progress and continue in the next message.]\n",
519 total_tool_calls
520 ),
521 ))
522 .await;
523 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
524 return;
525 }
526
527 tracing::debug!(
528 "Iteration {}: {} tool calls executed (total: {}/{}), continuing loop",
529 iteration,
530 tool_call_count,
531 total_tool_calls,
532 max_tool_calls
533 );
534 }
535 Err(e) => {
536 let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
537 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
538 return;
539 }
540 }
541 }
542
543 let _ = self
545 .frontend
546 .on_event(AgentEvent::Error(AgentError::InternalError(
547 format!("Max iterations reached ({})", max_iterations),
548 )))
549 .await;
550 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
551 }
552
553 async fn run_one_step(&mut self, session_id: &SessionId) -> Result<usize> {
556 let session = self
557 .sessions
558 .get_mut(session_id)
559 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
560
561 let truncation_result = self.context_manager.maybe_truncate(
563 &mut session.history,
564 session.last_known_prompt_tokens,
565 session.snapshot_message_count,
566 );
567
568 if truncation_result.needs_compression {
570 apply_compression_result(&self.llm_client, &mut session.history, &truncation_result).await;
571 session.last_known_prompt_tokens = None;
573 session.snapshot_message_count = 0;
574
575 tracing::info!(
576 "Compression completed: action={:?}, removed_rounds={}",
577 truncation_result.action, truncation_result.rounds_removed
578 );
579 } else if truncation_result.messages_removed > 0 {
580 session.last_known_prompt_tokens = None;
582 session.snapshot_message_count = 0;
583
584 tracing::info!(
585 "Context truncated without compression: {} messages removed",
586 truncation_result.messages_removed
587 );
588 }
589
590 let tools_param = if self.llm_client.supports_tools() {
595 let tool_schemas = self.tools.tool_schemas();
596 if tool_schemas.is_empty() {
597 None
598 } else {
599 Some(tool_schemas)
600 }
601 } else {
602 None
603 };
604
605 let estimated_prompt = self.context_manager.estimate_context_tokens(
607 &session.history,
608 session.last_known_prompt_tokens,
609 session.snapshot_message_count,
610 );
611 let calibration_tag = if session.last_known_prompt_tokens.is_some() {
612 "calibrated"
613 } else {
614 "heuristic"
615 };
616 tracing::info!(
617 "LLM call: ~{} prompt tokens ({}), {} messages",
618 estimated_prompt,
619 calibration_tag,
620 session.history.len(),
621 );
622
623 if !self.llm_client.supports_images() {
627 sanitize_history_for_model(&mut session.history, false);
628 }
629
630 let mut stream = match self
632 .llm_client
633 .chat_stream(session.history.clone(), tools_param)
634 .await
635 {
636 Ok(s) => s,
637 Err(e) => {
638 tracing::error!("LLM chat_stream failed: {:?}", e);
639 return Err(e.into());
640 }
641 };
642 tracing::trace!("LLM stream obtained, starting to collect response");
643
644 let mut full_text = String::new();
646 let mut tool_call_chunks: HashMap<usize, ToolCallAccumulator> = HashMap::new();
647 let mut api_usage: Option<async_openai::types::chat::CompletionUsage> = None;
648
649 let mut chunk_count = 0;
650 while let Some(chunk_result) = stream.next().await {
651 let chunk = match chunk_result {
652 Ok(c) => c,
653 Err(e) => {
654 tracing::error!("Stream chunk error: {:?}", e);
655 return Err(AgentError::LlmError(e.into()));
656 }
657 };
658 chunk_count += 1;
659
660 if let Some(ref usage) = chunk.usage {
662 api_usage = Some(usage.clone());
663 }
664
665 if let Some(choice) = chunk.choices.first() {
666 if let Some(content) = &choice.delta.content {
668 full_text.push_str(content);
669 let _ = self
670 .frontend
671 .on_event(AgentEvent::TextDelta(content.clone()))
672 .await;
673 }
674
675 if let Some(tool_calls) = &choice.delta.tool_calls {
677 for tc in tool_calls {
678 let acc = tool_call_chunks
679 .entry(tc.index as usize)
680 .or_insert_with(ToolCallAccumulator::new);
681
682 if let Some(id) = &tc.id {
683 if !id.is_empty() {
685 acc.id = Some(id.clone());
686 }
687 }
688 if let Some(function) = &tc.function {
689 if let Some(name) = &function.name {
690 if !name.is_empty() {
692 acc.name = Some(name.clone());
693 }
694 }
695 if let Some(args) = &function.arguments {
696 acc.arguments.push_str(args);
697 }
698 }
699 }
700 }
701 }
702 }
703
704 tracing::debug!("Stream collection complete: {} chunks, {} chars of text", chunk_count, full_text.len());
705
706 let assembled_tool_calls: Vec<ChatCompletionMessageToolCall> = {
708 let mut indices: Vec<usize> = tool_call_chunks.keys().cloned().collect();
709 indices.sort();
710 indices
711 .into_iter()
712 .filter_map(|idx| tool_call_chunks.remove(&idx)?.into_tool_call())
713 .collect()
714 };
715
716 let estimated_response = crate::context::estimate_tokens(&full_text);
718 if let Some(ref usage) = api_usage {
719 tracing::info!(
720 "LLM response: API usage = {} prompt + {} completion = {} total tokens. Estimated: ~{} prompt + ~{} response = ~{} total",
721 usage.prompt_tokens,
722 usage.completion_tokens,
723 usage.total_tokens,
724 estimated_prompt,
725 estimated_response,
726 estimated_prompt + estimated_response,
727 );
728 } else {
729 tracing::info!(
730 "LLM response: {} chars, ~{} estimated tokens ({} tool calls). API usage not available from streaming.",
731 full_text.len(),
732 estimated_response,
733 assembled_tool_calls.len(),
734 );
735 }
736
737 if let Some(ref usage) = api_usage {
742 session.last_known_prompt_tokens = Some(usage.prompt_tokens);
743 session.snapshot_message_count = session.history.len();
744 tracing::trace!(
745 "Token calibration updated: prompt_tokens={} at {} messages",
746 usage.prompt_tokens, session.history.len()
747 );
748 }
749
750 let content = if full_text.is_empty() {
752 None
753 } else {
754 Some(full_text.clone().into())
755 };
756 let tool_calls = if assembled_tool_calls.is_empty() {
757 None
758 } else {
759 Some(
760 assembled_tool_calls
761 .clone()
762 .into_iter()
763 .map(ChatCompletionMessageToolCalls::Function)
764 .collect(),
765 )
766 };
767
768 if content.is_some() || tool_calls.is_some() {
770 let assistant_msg = ChatCompletionRequestMessage::Assistant(
771 ChatCompletionRequestAssistantMessage {
772 content,
773 name: None,
774 tool_calls,
775 refusal: None,
776 audio: None,
777 #[allow(deprecated)]
778 function_call: None,
779 }
780 .into(),
781 );
782
783 session.history.push(assistant_msg);
784 } else {
785 tracing::warn!("Not adding empty assistant message to history (no content and no tool calls)");
786 }
787
788 if assembled_tool_calls.is_empty() {
790 return Ok(0);
791 }
792
793 self.execute_tool_calls(session_id, &assembled_tool_calls).await
794 }
795
796 async fn execute_tool_calls(
799 &mut self,
800 session_id: &SessionId,
801 assembled_tool_calls: &[ChatCompletionMessageToolCall],
802 ) -> Result<usize> {
803 let working_dir = {
805 let session = self
806 .sessions
807 .get(session_id)
808 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
809 session.working_dir.clone()
810 };
811
812 let mut batch_images: Vec<ToolImage> = Vec::new();
815
816 for (tc_idx, tc) in assembled_tool_calls.iter().enumerate() {
818 tracing::info!(
819 "Executing tool [{}/{}]: name='{}', id='{}', args={}",
820 tc_idx + 1,
821 assembled_tool_calls.len(),
822 tc.function.name,
823 tc.id,
824 truncate_for_log(&tc.function.arguments, 80)
825 );
826
827 let tc_info = ToolCallInfo {
828 id: tc.id.clone(),
829 name: tc.function.name.clone(),
830 arguments: tc.function.arguments.clone(),
831 };
832
833 if let Err(e) = self
838 .frontend
839 .on_event(AgentEvent::ToolCallRequested {
840 tool_call_id: tc_info.id.clone(),
841 name: tc_info.name.clone(),
842 arguments: tc_info.arguments.clone(),
843 })
844 .await
845 {
846 tracing::warn!(
847 "[tool] ToolCallRequested delivery FAILED (user feedback may be lost): tool_call_id='{}', name='{}', error={}",
848 tc_info.id,
849 tc_info.name,
850 e
851 );
852 }
853
854 let requires_confirm = self.tools.requires_confirmation(&tc.function.name);
856 let approved = if requires_confirm && !self.auto_approve {
857 tracing::trace!(
858 "[tool] requesting user confirmation: tool_call_id='{}', name='{}'",
859 tc_info.id,
860 tc_info.name
861 );
862 match self.frontend.request_tool_confirmation(&tc_info).await {
863 Ok(approved) => {
864 tracing::trace!(
865 "[tool] confirmation response: tool_call_id='{}', name='{}', approved={}",
866 tc_info.id,
867 tc_info.name,
868 approved
869 );
870 approved
871 }
872 Err(e) => {
873 tracing::warn!(
874 "[tool] confirmation request failed: tool_call_id='{}', name='{}', error={}",
875 tc_info.id,
876 tc_info.name,
877 e
878 );
879 return Err(e);
880 }
881 }
882 } else {
883 tracing::trace!(
884 "[tool] skipping confirmation (requires_confirm={}, auto_approve={})",
885 requires_confirm,
886 self.auto_approve
887 );
888 true
889 };
890
891 let result = if approved {
893 let args: serde_json::Value = serde_json::from_str(&tc.function.arguments)
894 .unwrap_or(serde_json::Value::Null);
895
896 let cancel_token = CancellationToken::new();
900
901 let ctx = ToolContext {
902 working_dir: working_dir.clone(),
903 session_id: session_id.clone(),
904 tool_call_id: tc.id.clone(),
905 frontend: self.frontend.clone(),
906 extensions: self.extensions.clone(),
907 supports_images: self.llm_client.supports_images(),
908 async_runner: self.async_runner.clone(),
909 cancel_token: cancel_token.clone(),
910 task_registry: self.task_registry.clone(),
911 };
912
913 let result = self.tools.execute(&tc.function.name, args, &ctx).await;
914 tracing::trace!(
915 "[tool] execution returned: tool_call_id='{}', name='{}', is_pending={}, is_error={}, content_len={}",
916 tc_info.id,
917 tc_info.name,
918 result.is_pending,
919 result.is_error,
920 result.content.len()
921 );
922
923 if result.is_pending {
928 if let Some(tid) = &result.pending_task_id {
929 tracing::info!(
930 "[async] task submitted: task_id={}, tool={}, tool_call_id={}",
931 tid,
932 tc.function.name,
933 tc.id
934 );
935 self.pending_tasks.insert(
936 tid.clone(),
937 PendingTask {
938 cancel: cancel_token,
939 tool_name: tc.function.name.clone(),
940 },
941 );
942 self.task_registry.register(AsyncTaskRecord {
943 task_id: tid.clone(),
944 tool_name: tc.function.name.clone(),
945 tool_call_id: tc.id.clone(),
946 session_id: session_id.clone(),
947 status: AsyncTaskStatus::Pending,
948 started_at: std::time::Instant::now(),
949 result_summary: None,
950 });
951 } else {
952 tracing::warn!(
953 "[async] tool {} returned is_pending without pending_task_id",
954 tc.function.name
955 );
956 }
957 }
958
959 result
960 } else {
961 tracing::trace!(
962 "[tool] tool call rejected by user: tool_call_id='{}', name='{}'",
963 tc_info.id,
964 tc_info.name
965 );
966 ToolResult::error("User rejected this tool call")
967 };
968
969 let raw_len = result.content.len();
971 let truncated_result = ToolResult {
972 content: self.context_manager.truncate_tool_output(&result.content),
973 is_error: result.is_error,
974 images: result.images.clone(),
975 is_pending: result.is_pending,
976 pending_task_id: result.pending_task_id.clone(),
977 };
978 if truncated_result.content.len() != raw_len {
979 tracing::trace!(
980 "[tool] output truncated: tool_call_id='{}', name='{}', raw_len={}, truncated_len={}",
981 tc_info.id,
982 tc_info.name,
983 raw_len,
984 truncated_result.content.len()
985 );
986 }
987
988 if let Err(e) = self
991 .frontend
992 .on_event(AgentEvent::ToolCallResult {
993 tool_call_id: tc.id.clone(),
994 result: truncated_result.clone(),
995 })
996 .await
997 {
998 tracing::warn!(
999 "[tool] ToolCallResult delivery FAILED (user feedback may be lost): tool_call_id='{}', name='{}', error={}",
1000 tc_info.id,
1001 tc_info.name,
1002 e
1003 );
1004 }
1005
1006 let tool_msg = ChatCompletionRequestMessage::Tool(
1008 ChatCompletionRequestToolMessage {
1009 content: truncated_result.content.into(),
1010 tool_call_id: tc.id.clone(),
1011 }
1012 .into(),
1013 );
1014
1015 let session = self
1016 .sessions
1017 .get_mut(session_id)
1018 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
1019 session.history.push(tool_msg);
1020
1021 batch_images.extend(truncated_result.images);
1024 }
1025
1026 if self.llm_client.supports_images() {
1033 if let Some(image_msg) = build_image_user_message(&batch_images) {
1034 let session = self
1035 .sessions
1036 .get_mut(session_id)
1037 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
1038 session.history.push(image_msg);
1039 }
1040 }
1041
1042 Ok(assembled_tool_calls.len())
1043 }
1044
1045 fn clear_session(&mut self) {
1047 if let Some(session) = self.sessions.get_mut(&self.default_session_id) {
1048 session.history.truncate(1);
1049 }
1050 }
1051
1052 async fn build_user_message(
1054 &self,
1055 text: &str,
1056 attachments: &[MediaAttachment],
1057 ) -> ChatCompletionRequestMessage {
1058 if self.llm_client.supports_images()
1060 && !attachments.is_empty()
1061 && attachments.iter().any(|a| a.is_image())
1062 {
1063 self.build_multimodal_message(text, attachments)
1064 .await
1065 } else {
1066 let mut full_text = text.to_string();
1068 for attachment in attachments {
1069 full_text = format!("{}\n{}", full_text, attachment.describe());
1070 }
1071 ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
1072 content: full_text.into(),
1073 name: None,
1074 })
1075 }
1076 }
1077
1078 async fn build_multimodal_message(
1080 &self,
1081 text: &str,
1082 attachments: &[MediaAttachment],
1083 ) -> ChatCompletionRequestMessage {
1084 let mut parts = vec![ChatCompletionRequestUserMessageContentPart::Text(
1085 ChatCompletionRequestMessageContentPartText {
1086 text: text.to_string(),
1087 prompt_cache_breakpoint: None,
1088 },
1089 )];
1090
1091 for attachment in attachments {
1093 if attachment.is_image() {
1094 match media::download_and_encode_base64(
1096 &attachment.url,
1097 &attachment.content_type,
1098 )
1099 .await
1100 {
1101 Ok(base64_url) => {
1102 parts.push(ChatCompletionRequestUserMessageContentPart::ImageUrl(
1103 ChatCompletionRequestMessageContentPartImage {
1104 image_url: ImageUrl {
1105 url: base64_url,
1106 detail: None,
1107 },
1108 prompt_cache_breakpoint: None,
1109 },
1110 ));
1111 }
1112 Err(e) => {
1113 tracing::warn!("Failed to encode image: {}", e);
1114 let desc = attachment.describe();
1116 let current_text = match &mut parts[0] {
1117 ChatCompletionRequestUserMessageContentPart::Text(t) => &mut t.text,
1118 _ => unreachable!(),
1119 };
1120 *current_text = format!("{}\n{}", current_text, desc);
1121 }
1122 }
1123 } else {
1124 let desc = attachment.describe();
1126 let current_text = match &mut parts[0] {
1127 ChatCompletionRequestUserMessageContentPart::Text(t) => &mut t.text,
1128 _ => unreachable!(),
1129 };
1130 *current_text = format!("{}\n{}", current_text, desc);
1131 }
1132 }
1133
1134 ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
1135 content: ChatCompletionRequestUserMessageContent::Array(parts),
1136 name: None,
1137 })
1138 }
1139
1140 async fn run_skill_turn(&mut self, skill: &crate::skill::Skill, args: &str) {
1145 let _ = self
1147 .frontend
1148 .on_event(AgentEvent::SkillTriggered {
1149 name: skill.frontmatter.name.clone(),
1150 description: skill.frontmatter.description.clone(),
1151 })
1152 .await;
1153
1154 let session_id = self.default_session_id.clone();
1155
1156 let skill_message = format!(
1158 "## Skill: {}\n\n{}\n\n{}",
1159 skill.frontmatter.name,
1160 skill.frontmatter.description,
1161 skill.content
1162 );
1163
1164 let skill_msg = ChatCompletionRequestMessage::System(
1165 ChatCompletionRequestSystemMessage {
1166 content: skill_message.into(),
1167 name: Some(skill.frontmatter.name.clone()),
1168 }
1169 .into(),
1170 );
1171
1172 if let Some(session) = self.sessions.get_mut(&session_id) {
1173 session.history.push(skill_msg);
1174 }
1175
1176 let user_content = if args.is_empty() {
1178 "(User triggered skill, no additional arguments)".to_string()
1179 } else {
1180 args.to_string()
1181 };
1182
1183 if let Some(session) = self.sessions.get_mut(&session_id) {
1184 session.history.push(ChatCompletionRequestMessage::User(
1185 ChatCompletionRequestUserMessage {
1186 content: user_content.into(),
1187 name: None,
1188 }
1189 .into(),
1190 ));
1191 }
1192
1193 let max_iterations = 20;
1195 let mut completed = false;
1196 for iteration in 0..max_iterations {
1197 match self.run_one_step(&session_id).await {
1198 Ok(tool_call_count) => {
1199 if tool_call_count == 0 {
1200 completed = true;
1201 break;
1202 }
1203 tracing::debug!(
1204 "Skill iteration {}: tool calls executed",
1205 iteration
1206 );
1207 }
1208 Err(e) => {
1209 let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
1210 break;
1211 }
1212 }
1213 }
1214
1215 if !completed {
1216 let _ = self
1217 .frontend
1218 .on_event(AgentEvent::Error(AgentError::InternalError(
1219 format!("Max iterations reached ({})", max_iterations),
1220 )))
1221 .await;
1222 }
1223
1224 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
1225
1226 if let Some(session) = self.sessions.get_mut(&session_id) {
1228 let skill_name = skill.frontmatter.name.clone();
1229 session.history.retain(|msg| {
1230 !matches!(
1231 msg,
1232 ChatCompletionRequestMessage::System(s)
1233 if s.name.as_deref() == Some(&skill_name)
1234 )
1235 });
1236 }
1237 }
1238
1239 async fn handle_async_done(&mut self, done: AsyncTaskDone) {
1242 tracing::info!(
1243 "[async] task done: task_id={}, tool={}, session={}, cancelled={}, is_error={}",
1244 done.task_id,
1245 done.tool_name,
1246 done.session_id,
1247 done.cancelled,
1248 done.result.is_error
1249 );
1250
1251 self.pending_tasks.remove(&done.task_id);
1253
1254 let status = if done.cancelled {
1256 AsyncTaskStatus::Cancelled
1257 } else if done.result.is_error {
1258 AsyncTaskStatus::Failed
1259 } else {
1260 AsyncTaskStatus::Completed
1261 };
1262 let summary = summarize_result(&done.result.content);
1263 self.task_registry
1264 .update(&done.task_id, status, Some(summary));
1265
1266 let _ = self
1269 .frontend
1270 .on_event(AgentEvent::AsyncToolCompleted {
1271 task_id: done.task_id.clone(),
1272 tool_call_id: done.tool_call_id.clone(),
1273 result: done.result.clone(),
1274 })
1275 .await;
1276
1277 let session_id = done.session_id.clone();
1281 if !self.sessions.contains_key(&session_id) {
1282 tracing::error!(
1283 "[async] task {} (tool={}) finished but session {} not found; dropping result. \
1284 This means the Agent exited or the session was cleaned up before the task completed. \
1285 Result: {} chars, is_error={}, cancelled={}",
1286 done.task_id, done.tool_name, session_id,
1287 done.result.content.len(), done.result.is_error, done.cancelled
1288 );
1289 return;
1290 }
1291
1292 let notice = format!(
1296 "[后台任务完成通知] task_id={} (工具: {})\n{}",
1297 done.task_id, done.tool_name, done.result.content
1298 );
1299 if let Some(session) = self.sessions.get_mut(&session_id) {
1300 session.history.push(ChatCompletionRequestMessage::User(
1301 ChatCompletionRequestUserMessage {
1302 content: notice.into(),
1303 name: None,
1304 },
1305 ));
1306
1307 if self.llm_client.supports_images() {
1310 if let Some(image_msg) = build_image_user_message(&done.result.images) {
1311 session.history.push(image_msg);
1312 }
1313 }
1314 }
1315
1316 self.run_agent_loop(&session_id).await;
1318 }
1319
1320 async fn handle_cancel_task(&mut self, task_id: &str) {
1323 match self.pending_tasks.remove(task_id) {
1324 Some(pending) => {
1325 tracing::info!(
1326 "[async] cancelling task {} (tool={})",
1327 task_id,
1328 pending.tool_name
1329 );
1330 pending.cancel.cancel();
1331 }
1332 None => {
1333 tracing::warn!("[async] cancel request for unknown task {}", task_id);
1334 }
1335 }
1336 }
1337
1338 async fn handle_cancel_all(&mut self) {
1340 let count = self.pending_tasks.len();
1341 if count == 0 {
1342 tracing::info!("[async] Cancel requested, no pending tasks");
1343 return;
1344 }
1345 tracing::info!("[async] cancelling all {} pending task(s)", count);
1346 for (_, pending) in self.pending_tasks.drain() {
1347 pending.cancel.cancel();
1348 }
1349 }
1350}
1351
1352impl Drop for Agent {
1353 fn drop(&mut self) {
1354 let count = self.pending_tasks.len();
1357 if count > 0 {
1358 tracing::info!(
1359 "[async] Agent dropped, cancelling {} pending task(s)",
1360 count
1361 );
1362 for (_, pending) in self.pending_tasks.drain() {
1363 pending.cancel.cancel();
1364 }
1365 }
1366 }
1367}
1368
1369async fn apply_compression_result(
1381 llm_client: &LlmClient,
1382 history: &mut [ChatCompletionRequestMessage],
1383 result: &TruncationResult,
1384) {
1385 if !result.needs_compression {
1386 return;
1387 }
1388
1389 let pos = result.insert_position;
1390 if pos >= history.len() {
1391 tracing::warn!("Insert position {} out of bounds (history len: {})", pos, history.len());
1392 return;
1393 }
1394
1395 let (content, name) = match &result.action {
1396 TruncationAction::NewSegment => {
1397 let summary = generate_summary(llm_client, &result.removed_messages).await;
1398 (
1399 format!("[Summary: {}]", summary),
1400 "summary_segment".to_string(),
1401 )
1402 }
1403 TruncationAction::MergeSegments { summaries, .. } => {
1404 let merged = merge_summaries(llm_client, summaries).await;
1405 let current_level = crate::context::get_merge_level(&history[pos]);
1407 let name = if current_level == 0 {
1408 "summary_segment".to_string()
1409 } else {
1410 format!("summary_segment_m{}", current_level)
1411 };
1412 (format!("[Summary: {}]", merged), name)
1413 }
1414 TruncationAction::TruncateOnly => return,
1415 };
1416
1417 tracing::info!("Compression applied at position {}: {}", pos, name);
1418
1419 history[pos] = ChatCompletionRequestMessage::User(
1420 ChatCompletionRequestUserMessage {
1421 content: content.into(),
1422 name: Some(name),
1423 }
1424 );
1425}
1426
1427async fn generate_summary(
1429 llm_client: &LlmClient,
1430 removed_messages: &[ChatCompletionRequestMessage],
1431) -> String {
1432 tracing::debug!("Generating summary: removed_messages count = {}", removed_messages.len());
1433 let transcript = crate::context::format_removed_messages_as_transcript(removed_messages);
1434 tracing::debug!("Formatted transcript length: {} characters", transcript.len());
1435
1436 let system_prompt = "Summarize the following conversation transcript in 1-2 concise sentences. Focus on: what the user asked for, what actions were taken, and the outcomes. Be brief and factual.";
1437
1438 let messages = vec![
1439 ChatCompletionRequestMessage::System(
1440 ChatCompletionRequestSystemMessage {
1441 content: system_prompt.into(),
1442 name: None,
1443 }
1444 ),
1445 ChatCompletionRequestMessage::User(
1446 ChatCompletionRequestUserMessage {
1447 content: format!("Conversation transcript:\n\n{}", transcript).into(),
1448 name: None,
1449 }
1450 ),
1451 ];
1452
1453 tracing::info!("Calling LLM to generate summary...");
1454 match llm_client.chat(messages, None).await {
1455 Ok(response) => {
1456 tracing::info!("LLM responded successfully for summary generation");
1457 tracing::debug!("Number of choices in response: {}", response.choices.len());
1458 if let Some(choice) = response.choices.first() {
1459 tracing::debug!("Choice index: 0, has content: {}", choice.message.content.is_some());
1460 if let Some(content) = &choice.message.content {
1461 let summary = content.trim().to_string();
1462 if !summary.is_empty() {
1463 tracing::info!("Successfully generated summary (length: {})", summary.len());
1464 return summary;
1465 }
1466 }
1467 }
1468 tracing::warn!("Summary generation returned empty response, using fallback");
1469 "Conversation history compressed.".to_string()
1470 }
1471 Err(e) => {
1472 tracing::error!("Summary generation failed with error: {}, using fallback", e);
1473 "Conversation history compressed.".to_string()
1474 }
1475 }
1476}
1477
1478async fn merge_summaries(
1480 llm_client: &LlmClient,
1481 summaries: &[String],
1482) -> String {
1483 tracing::info!("Merging {} summary segments...", summaries.len());
1484
1485 let numbered: Vec<String> = summaries
1486 .iter()
1487 .enumerate()
1488 .map(|(i, s)| format!("[{}] {}", i + 1, s))
1489 .collect();
1490 let joined = numbered.join("\n\n");
1491
1492 let system_prompt = "You are given multiple conversation summaries from different time periods, ordered from oldest to newest. Merge them into a single concise summary (2-3 sentences) that preserves all key information.
1493
1494Key points to preserve:
1495- User goals and requests
1496- Important decisions made
1497- Technical context (file paths, APIs, architectures)
1498- Major outcomes and conclusions
1499
1500Do not simply concatenate — synthesize into a coherent narrative.";
1501
1502 let messages = vec![
1503 ChatCompletionRequestMessage::System(
1504 ChatCompletionRequestSystemMessage {
1505 content: system_prompt.into(),
1506 name: None,
1507 }
1508 ),
1509 ChatCompletionRequestMessage::User(
1510 ChatCompletionRequestUserMessage {
1511 content: format!("Summaries to merge:\n\n{}", joined).into(),
1512 name: None,
1513 }
1514 ),
1515 ];
1516
1517 match llm_client.chat(messages, None).await {
1518 Ok(response) => {
1519 if let Some(choice) = response.choices.first() {
1520 if let Some(content) = &choice.message.content {
1521 let summary = content.trim().to_string();
1522 if !summary.is_empty() {
1523 tracing::info!("Successfully merged {} summaries (length: {})", summaries.len(), summary.len());
1524 return summary;
1525 }
1526 }
1527 }
1528 tracing::warn!("Summary merge returned empty response, using fallback");
1529 "Multiple earlier conversation segments merged.".to_string()
1530 }
1531 Err(e) => {
1532 tracing::error!("Summary merge failed with error: {}, using fallback", e);
1533 "Multiple earlier conversation segments merged.".to_string()
1534 }
1535 }
1536}
1537
1538#[derive(Debug)]
1544struct ToolCallAccumulator {
1545 id: Option<String>,
1546 name: Option<String>,
1547 arguments: String,
1548}
1549
1550impl ToolCallAccumulator {
1551 fn new() -> Self {
1552 Self {
1553 id: None,
1554 name: None,
1555 arguments: String::new(),
1556 }
1557 }
1558
1559 fn into_tool_call(self) -> Option<ChatCompletionMessageToolCall> {
1561 let id = self.id?;
1562 let name = self.name?;
1563
1564 tracing::trace!(
1565 "Tool call assembled: id='{}', name='{}', args={}",
1566 id,
1567 name,
1568 truncate_for_log(&self.arguments, 80)
1569 );
1570
1571 Some(ChatCompletionMessageToolCall {
1572 id,
1573 function: FunctionCall {
1574 name,
1575 arguments: self.arguments,
1576 },
1577 })
1578 }
1579}
1580
1581fn truncate_for_log(s: &str, max_chars: usize) -> String {
1585 let char_count = s.chars().count();
1586 if char_count <= max_chars {
1587 s.to_string()
1588 } else {
1589 let preview: String = s.chars().take(max_chars).collect();
1590 format!("{}... ({} chars total)", preview, char_count)
1591 }
1592}
1593
1594fn build_image_user_message(images: &[ToolImage]) -> Option<ChatCompletionRequestMessage> {
1598 if images.is_empty() {
1599 return None;
1600 }
1601 let mut parts = vec![ChatCompletionRequestUserMessageContentPart::Text(
1602 ChatCompletionRequestMessageContentPartText {
1603 text: format!(
1604 "[工具返回的图片] {}",
1605 images
1606 .iter()
1607 .map(|i| i.label.as_str())
1608 .collect::<Vec<_>>()
1609 .join(", ")
1610 ),
1611 prompt_cache_breakpoint: None,
1612 },
1613 )];
1614 for img in images {
1615 parts.push(ChatCompletionRequestUserMessageContentPart::ImageUrl(
1616 ChatCompletionRequestMessageContentPartImage {
1617 image_url: ImageUrl {
1618 url: img.data_url.clone(),
1619 detail: None,
1620 },
1621 prompt_cache_breakpoint: None,
1622 },
1623 ));
1624 }
1625 Some(ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
1626 content: ChatCompletionRequestUserMessageContent::Array(parts),
1627 name: None,
1628 }))
1629}
1630
1631fn sanitize_history_for_model(
1639 history: &mut Vec<ChatCompletionRequestMessage>,
1640 supports_images: bool,
1641) {
1642 if supports_images {
1643 return;
1644 }
1645
1646 let mut sanitized_count = 0usize;
1647 for msg in history.iter_mut() {
1648 if let ChatCompletionRequestMessage::User(user_msg) = msg {
1649 if let ChatCompletionRequestUserMessageContent::Array(parts) = &user_msg.content {
1650 let has_image = parts
1652 .iter()
1653 .any(|p| matches!(p, ChatCompletionRequestUserMessageContentPart::ImageUrl(_)));
1654 if has_image {
1655 let text: String = parts
1657 .iter()
1658 .filter_map(|p| {
1659 if let ChatCompletionRequestUserMessageContentPart::Text(t) = p {
1660 Some(t.text.as_str())
1661 } else {
1662 None
1663 }
1664 })
1665 .collect::<Vec<_>>()
1666 .join("\n");
1667
1668 user_msg.content = ChatCompletionRequestUserMessageContent::Text(text);
1669 sanitized_count += 1;
1670 }
1671 }
1672 }
1673 }
1674
1675 if sanitized_count > 0 {
1676 tracing::info!(
1677 "sanitize_history_for_model: downgraded {} message(s) with image_url to text \
1678 (model does not support images)",
1679 sanitized_count
1680 );
1681 }
1682}
1683
1684fn summarize_result(content: &str) -> String {
1686 const MAX: usize = 500;
1687 let char_count = content.chars().count();
1688 if char_count <= MAX {
1689 content.to_string()
1690 } else {
1691 let truncated: String = content.chars().take(MAX).collect();
1692 format!("{}... (truncated, {} chars total)", truncated, char_count)
1693 }
1694}
1695
1696#[cfg(test)]
1697mod tests {
1698 use super::*;
1699 use crate::event::AgentEvent;
1700 use crate::frontend::Frontend;
1701 use crate::skill::SkillRegistry;
1702 use crate::tool::{Tool, ToolContext};
1703 use async_trait::async_trait;
1704 use robit_ai::config::{ModelConfig, ProviderConfig, RobitConfig};
1705 use serde_json::Value;
1706
1707 struct NoopFrontend;
1709
1710 #[async_trait]
1711 impl Frontend for NoopFrontend {
1712 async fn on_event(&self, _event: AgentEvent) -> Result<()> {
1713 Ok(())
1714 }
1715
1716 async fn request_tool_confirmation(&self, _info: &ToolCallInfo) -> Result<bool> {
1717 Ok(true)
1718 }
1719 }
1720
1721 struct ImageTool;
1724
1725 #[async_trait]
1726 impl Tool for ImageTool {
1727 fn name(&self) -> &str {
1728 "fake_image_tool"
1729 }
1730
1731 fn description(&self) -> &str {
1732 "Returns an image"
1733 }
1734
1735 fn parameters_schema(&self) -> Value {
1736 serde_json::json!({"type": "object", "properties": {}})
1737 }
1738
1739 fn requires_confirmation(&self) -> bool {
1740 false
1741 }
1742
1743 async fn execute(&self, _args: Value, _ctx: &ToolContext) -> Result<ToolResult> {
1744 Ok(ToolResult {
1745 content: "Image file: x.png".to_string(),
1746 is_error: false,
1747 images: vec![ToolImage {
1748 data_url: "data:image/png;base64,Zm9v".to_string(),
1749 label: "x.png".to_string(),
1750 }],
1751 is_pending: false,
1752 pending_task_id: None,
1753 })
1754 }
1755 }
1756
1757 fn vision_llm_client() -> Arc<LlmClient> {
1760 let config = RobitConfig {
1761 default_model: Some("test/vision".to_string()),
1762 providers: HashMap::from([(
1763 "test".to_string(),
1764 ProviderConfig {
1765 name: Some("Test".to_string()),
1766 base_url: "http://127.0.0.1:1".to_string(),
1767 api_key: "sk-test".to_string(),
1768 models: vec![ModelConfig {
1769 id: "vision".to_string(),
1770 name: Some("Vision".to_string()),
1771 context_window: None,
1772 max_output_tokens: None,
1773 temperature: None,
1774 max_tokens: None,
1775 supports_images: Some(true),
1776 supports_tools: Some(true),
1777 }],
1778 },
1779 )]),
1780 app: None,
1781 channels: None,
1782 default_image_model: None,
1783 image_providers: HashMap::new(),
1784 };
1785 Arc::new(LlmClient::from_config(&config, None).unwrap())
1786 }
1787
1788 fn tool_call(id: &str) -> ChatCompletionMessageToolCall {
1789 ChatCompletionMessageToolCall {
1790 id: id.to_string(),
1791 function: FunctionCall {
1792 name: "fake_image_tool".to_string(),
1793 arguments: "{}".to_string(),
1794 },
1795 }
1796 }
1797
1798 #[tokio::test]
1803 async fn parallel_image_tool_results_keep_tool_messages_contiguous() {
1804 let mut tools = ToolRegistry::new();
1805 tools.register(ImageTool);
1806 let mut agent = Agent::new(
1807 vision_llm_client(),
1808 Arc::new(tools),
1809 Arc::new(SkillRegistry::new(vec![], &[])),
1810 Arc::new(NoopFrontend),
1811 None,
1812 None,
1813 PathBuf::from("."),
1814 true,
1815 HashMap::new(),
1816 );
1817
1818 let session_id = agent.default_session_id.clone();
1819 let calls = vec![tool_call("call_0"), tool_call("call_1"), tool_call("call_2")];
1820 let executed = agent.execute_tool_calls(&session_id, &calls).await.unwrap();
1821 assert_eq!(executed, 3);
1822
1823 let session = agent.sessions.get(&session_id).unwrap();
1824 assert_eq!(session.history.len(), 5, "3 tool messages + 1 image user message");
1827 let kinds: Vec<&str> = session
1828 .history
1829 .iter()
1830 .skip(1)
1831 .map(|m| match m {
1832 ChatCompletionRequestMessage::Tool(_) => "tool",
1833 ChatCompletionRequestMessage::User(_) => "user",
1834 ChatCompletionRequestMessage::Assistant(_) => "assistant",
1835 _ => "other",
1836 })
1837 .collect();
1838 assert_eq!(
1839 kinds,
1840 vec!["tool", "tool", "tool", "user"],
1841 "tool responses must be contiguous after the assistant tool_calls \
1842 message; image user message(s) go after the batch"
1843 );
1844 }
1845}