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 working_dir = {
558 let session = self
559 .sessions
560 .get(session_id)
561 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
562 session.working_dir.clone()
563 };
564
565 let session = self
566 .sessions
567 .get_mut(session_id)
568 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
569
570 let truncation_result = self.context_manager.maybe_truncate(
572 &mut session.history,
573 session.last_known_prompt_tokens,
574 session.snapshot_message_count,
575 );
576
577 if truncation_result.needs_compression {
579 apply_compression_result(&self.llm_client, &mut session.history, &truncation_result).await;
580 session.last_known_prompt_tokens = None;
582 session.snapshot_message_count = 0;
583
584 tracing::info!(
585 "Compression completed: action={:?}, removed_rounds={}",
586 truncation_result.action, truncation_result.rounds_removed
587 );
588 } else if truncation_result.messages_removed > 0 {
589 session.last_known_prompt_tokens = None;
591 session.snapshot_message_count = 0;
592
593 tracing::info!(
594 "Context truncated without compression: {} messages removed",
595 truncation_result.messages_removed
596 );
597 }
598
599 let tools_param = if self.llm_client.supports_tools() {
604 let tool_schemas = self.tools.tool_schemas();
605 if tool_schemas.is_empty() {
606 None
607 } else {
608 Some(tool_schemas)
609 }
610 } else {
611 None
612 };
613
614 let estimated_prompt = self.context_manager.estimate_context_tokens(
616 &session.history,
617 session.last_known_prompt_tokens,
618 session.snapshot_message_count,
619 );
620 let calibration_tag = if session.last_known_prompt_tokens.is_some() {
621 "calibrated"
622 } else {
623 "heuristic"
624 };
625 tracing::info!(
626 "LLM call: ~{} prompt tokens ({}), {} messages",
627 estimated_prompt,
628 calibration_tag,
629 session.history.len(),
630 );
631
632 if !self.llm_client.supports_images() {
636 sanitize_history_for_model(&mut session.history, false);
637 }
638
639 let mut stream = match self
641 .llm_client
642 .chat_stream(session.history.clone(), tools_param)
643 .await
644 {
645 Ok(s) => s,
646 Err(e) => {
647 tracing::error!("LLM chat_stream failed: {:?}", e);
648 return Err(e.into());
649 }
650 };
651 tracing::trace!("LLM stream obtained, starting to collect response");
652
653 let mut full_text = String::new();
655 let mut tool_call_chunks: HashMap<usize, ToolCallAccumulator> = HashMap::new();
656 let mut api_usage: Option<async_openai::types::chat::CompletionUsage> = None;
657
658 let mut chunk_count = 0;
659 while let Some(chunk_result) = stream.next().await {
660 let chunk = match chunk_result {
661 Ok(c) => c,
662 Err(e) => {
663 tracing::error!("Stream chunk error: {:?}", e);
664 return Err(AgentError::LlmError(e.into()));
665 }
666 };
667 chunk_count += 1;
668
669 if let Some(ref usage) = chunk.usage {
671 api_usage = Some(usage.clone());
672 }
673
674 if let Some(choice) = chunk.choices.first() {
675 if let Some(content) = &choice.delta.content {
677 full_text.push_str(content);
678 let _ = self
679 .frontend
680 .on_event(AgentEvent::TextDelta(content.clone()))
681 .await;
682 }
683
684 if let Some(tool_calls) = &choice.delta.tool_calls {
686 for tc in tool_calls {
687 let acc = tool_call_chunks
688 .entry(tc.index as usize)
689 .or_insert_with(ToolCallAccumulator::new);
690
691 if let Some(id) = &tc.id {
692 if !id.is_empty() {
694 acc.id = Some(id.clone());
695 }
696 }
697 if let Some(function) = &tc.function {
698 if let Some(name) = &function.name {
699 if !name.is_empty() {
701 acc.name = Some(name.clone());
702 }
703 }
704 if let Some(args) = &function.arguments {
705 acc.arguments.push_str(args);
706 }
707 }
708 }
709 }
710 }
711 }
712
713 tracing::debug!("Stream collection complete: {} chunks, {} chars of text", chunk_count, full_text.len());
714
715 let assembled_tool_calls: Vec<ChatCompletionMessageToolCall> = {
717 let mut indices: Vec<usize> = tool_call_chunks.keys().cloned().collect();
718 indices.sort();
719 indices
720 .into_iter()
721 .filter_map(|idx| tool_call_chunks.remove(&idx)?.into_tool_call())
722 .collect()
723 };
724
725 let estimated_response = crate::context::estimate_tokens(&full_text);
727 if let Some(ref usage) = api_usage {
728 tracing::info!(
729 "LLM response: API usage = {} prompt + {} completion = {} total tokens. Estimated: ~{} prompt + ~{} response = ~{} total",
730 usage.prompt_tokens,
731 usage.completion_tokens,
732 usage.total_tokens,
733 estimated_prompt,
734 estimated_response,
735 estimated_prompt + estimated_response,
736 );
737 } else {
738 tracing::info!(
739 "LLM response: {} chars, ~{} estimated tokens ({} tool calls). API usage not available from streaming.",
740 full_text.len(),
741 estimated_response,
742 assembled_tool_calls.len(),
743 );
744 }
745
746 if let Some(ref usage) = api_usage {
751 session.last_known_prompt_tokens = Some(usage.prompt_tokens);
752 session.snapshot_message_count = session.history.len();
753 tracing::trace!(
754 "Token calibration updated: prompt_tokens={} at {} messages",
755 usage.prompt_tokens, session.history.len()
756 );
757 }
758
759 let content = if full_text.is_empty() {
761 None
762 } else {
763 Some(full_text.clone().into())
764 };
765 let tool_calls = if assembled_tool_calls.is_empty() {
766 None
767 } else {
768 Some(
769 assembled_tool_calls
770 .clone()
771 .into_iter()
772 .map(ChatCompletionMessageToolCalls::Function)
773 .collect(),
774 )
775 };
776
777 if content.is_some() || tool_calls.is_some() {
779 let assistant_msg = ChatCompletionRequestMessage::Assistant(
780 ChatCompletionRequestAssistantMessage {
781 content,
782 name: None,
783 tool_calls,
784 refusal: None,
785 audio: None,
786 #[allow(deprecated)]
787 function_call: None,
788 }
789 .into(),
790 );
791
792 session.history.push(assistant_msg);
793 } else {
794 tracing::warn!("Not adding empty assistant message to history (no content and no tool calls)");
795 }
796
797 if assembled_tool_calls.is_empty() {
799 return Ok(0);
800 }
801
802 for (tc_idx, tc) in assembled_tool_calls.iter().enumerate() {
804 tracing::info!(
805 "Executing tool [{}/{}]: name='{}', id='{}', args={}",
806 tc_idx + 1,
807 assembled_tool_calls.len(),
808 tc.function.name,
809 tc.id,
810 truncate_for_log(&tc.function.arguments, 80)
811 );
812
813 let tc_info = ToolCallInfo {
814 id: tc.id.clone(),
815 name: tc.function.name.clone(),
816 arguments: tc.function.arguments.clone(),
817 };
818
819 if let Err(e) = self
824 .frontend
825 .on_event(AgentEvent::ToolCallRequested {
826 tool_call_id: tc_info.id.clone(),
827 name: tc_info.name.clone(),
828 arguments: tc_info.arguments.clone(),
829 })
830 .await
831 {
832 tracing::warn!(
833 "[tool] ToolCallRequested delivery FAILED (user feedback may be lost): tool_call_id='{}', name='{}', error={}",
834 tc_info.id,
835 tc_info.name,
836 e
837 );
838 }
839
840 let requires_confirm = self.tools.requires_confirmation(&tc.function.name);
842 let approved = if requires_confirm && !self.auto_approve {
843 tracing::trace!(
844 "[tool] requesting user confirmation: tool_call_id='{}', name='{}'",
845 tc_info.id,
846 tc_info.name
847 );
848 match self.frontend.request_tool_confirmation(&tc_info).await {
849 Ok(approved) => {
850 tracing::trace!(
851 "[tool] confirmation response: tool_call_id='{}', name='{}', approved={}",
852 tc_info.id,
853 tc_info.name,
854 approved
855 );
856 approved
857 }
858 Err(e) => {
859 tracing::warn!(
860 "[tool] confirmation request failed: tool_call_id='{}', name='{}', error={}",
861 tc_info.id,
862 tc_info.name,
863 e
864 );
865 return Err(e);
866 }
867 }
868 } else {
869 tracing::trace!(
870 "[tool] skipping confirmation (requires_confirm={}, auto_approve={})",
871 requires_confirm,
872 self.auto_approve
873 );
874 true
875 };
876
877 let result = if approved {
879 let args: serde_json::Value = serde_json::from_str(&tc.function.arguments)
880 .unwrap_or(serde_json::Value::Null);
881
882 let cancel_token = CancellationToken::new();
886
887 let ctx = ToolContext {
888 working_dir: working_dir.clone(),
889 session_id: session_id.clone(),
890 tool_call_id: tc.id.clone(),
891 frontend: self.frontend.clone(),
892 extensions: self.extensions.clone(),
893 supports_images: self.llm_client.supports_images(),
894 async_runner: self.async_runner.clone(),
895 cancel_token: cancel_token.clone(),
896 task_registry: self.task_registry.clone(),
897 };
898
899 let result = self.tools.execute(&tc.function.name, args, &ctx).await;
900 tracing::trace!(
901 "[tool] execution returned: tool_call_id='{}', name='{}', is_pending={}, is_error={}, content_len={}",
902 tc_info.id,
903 tc_info.name,
904 result.is_pending,
905 result.is_error,
906 result.content.len()
907 );
908
909 if result.is_pending {
914 if let Some(tid) = &result.pending_task_id {
915 tracing::info!(
916 "[async] task submitted: task_id={}, tool={}, tool_call_id={}",
917 tid,
918 tc.function.name,
919 tc.id
920 );
921 self.pending_tasks.insert(
922 tid.clone(),
923 PendingTask {
924 cancel: cancel_token,
925 tool_name: tc.function.name.clone(),
926 },
927 );
928 self.task_registry.register(AsyncTaskRecord {
929 task_id: tid.clone(),
930 tool_name: tc.function.name.clone(),
931 tool_call_id: tc.id.clone(),
932 session_id: session_id.clone(),
933 status: AsyncTaskStatus::Pending,
934 started_at: std::time::Instant::now(),
935 result_summary: None,
936 });
937 } else {
938 tracing::warn!(
939 "[async] tool {} returned is_pending without pending_task_id",
940 tc.function.name
941 );
942 }
943 }
944
945 result
946 } else {
947 tracing::trace!(
948 "[tool] tool call rejected by user: tool_call_id='{}', name='{}'",
949 tc_info.id,
950 tc_info.name
951 );
952 ToolResult::error("User rejected this tool call")
953 };
954
955 let raw_len = result.content.len();
957 let truncated_result = ToolResult {
958 content: self.context_manager.truncate_tool_output(&result.content),
959 is_error: result.is_error,
960 images: result.images.clone(),
961 is_pending: result.is_pending,
962 pending_task_id: result.pending_task_id.clone(),
963 };
964 if truncated_result.content.len() != raw_len {
965 tracing::trace!(
966 "[tool] output truncated: tool_call_id='{}', name='{}', raw_len={}, truncated_len={}",
967 tc_info.id,
968 tc_info.name,
969 raw_len,
970 truncated_result.content.len()
971 );
972 }
973
974 if let Err(e) = self
977 .frontend
978 .on_event(AgentEvent::ToolCallResult {
979 tool_call_id: tc.id.clone(),
980 result: truncated_result.clone(),
981 })
982 .await
983 {
984 tracing::warn!(
985 "[tool] ToolCallResult delivery FAILED (user feedback may be lost): tool_call_id='{}', name='{}', error={}",
986 tc_info.id,
987 tc_info.name,
988 e
989 );
990 }
991
992 let tool_msg = ChatCompletionRequestMessage::Tool(
994 ChatCompletionRequestToolMessage {
995 content: truncated_result.content.into(),
996 tool_call_id: tc.id.clone(),
997 }
998 .into(),
999 );
1000
1001 let session = self
1002 .sessions
1003 .get_mut(session_id)
1004 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
1005 session.history.push(tool_msg);
1006
1007
1008 if self.llm_client.supports_images() {
1013 if let Some(image_msg) = build_image_user_message(&truncated_result.images) {
1014 session.history.push(image_msg);
1015 }
1016 }
1017 }
1018
1019 Ok(assembled_tool_calls.len())
1020 }
1021
1022 fn clear_session(&mut self) {
1024 if let Some(session) = self.sessions.get_mut(&self.default_session_id) {
1025 session.history.truncate(1);
1026 }
1027 }
1028
1029 async fn build_user_message(
1031 &self,
1032 text: &str,
1033 attachments: &[MediaAttachment],
1034 ) -> ChatCompletionRequestMessage {
1035 if self.llm_client.supports_images()
1037 && !attachments.is_empty()
1038 && attachments.iter().any(|a| a.is_image())
1039 {
1040 self.build_multimodal_message(text, attachments)
1041 .await
1042 } else {
1043 let mut full_text = text.to_string();
1045 for attachment in attachments {
1046 full_text = format!("{}\n{}", full_text, attachment.describe());
1047 }
1048 ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
1049 content: full_text.into(),
1050 name: None,
1051 })
1052 }
1053 }
1054
1055 async fn build_multimodal_message(
1057 &self,
1058 text: &str,
1059 attachments: &[MediaAttachment],
1060 ) -> ChatCompletionRequestMessage {
1061 let mut parts = vec![ChatCompletionRequestUserMessageContentPart::Text(
1062 ChatCompletionRequestMessageContentPartText {
1063 text: text.to_string(),
1064 },
1065 )];
1066
1067 for attachment in attachments {
1069 if attachment.is_image() {
1070 match media::download_and_encode_base64(
1072 &attachment.url,
1073 &attachment.content_type,
1074 )
1075 .await
1076 {
1077 Ok(base64_url) => {
1078 parts.push(ChatCompletionRequestUserMessageContentPart::ImageUrl(
1079 ChatCompletionRequestMessageContentPartImage {
1080 image_url: ImageUrl {
1081 url: base64_url,
1082 detail: None,
1083 },
1084 },
1085 ));
1086 }
1087 Err(e) => {
1088 tracing::warn!("Failed to encode image: {}", e);
1089 let desc = attachment.describe();
1091 let current_text = match &mut parts[0] {
1092 ChatCompletionRequestUserMessageContentPart::Text(t) => &mut t.text,
1093 _ => unreachable!(),
1094 };
1095 *current_text = format!("{}\n{}", current_text, desc);
1096 }
1097 }
1098 } else {
1099 let desc = attachment.describe();
1101 let current_text = match &mut parts[0] {
1102 ChatCompletionRequestUserMessageContentPart::Text(t) => &mut t.text,
1103 _ => unreachable!(),
1104 };
1105 *current_text = format!("{}\n{}", current_text, desc);
1106 }
1107 }
1108
1109 ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
1110 content: ChatCompletionRequestUserMessageContent::Array(parts),
1111 name: None,
1112 })
1113 }
1114
1115 async fn run_skill_turn(&mut self, skill: &crate::skill::Skill, args: &str) {
1120 let _ = self
1122 .frontend
1123 .on_event(AgentEvent::SkillTriggered {
1124 name: skill.frontmatter.name.clone(),
1125 description: skill.frontmatter.description.clone(),
1126 })
1127 .await;
1128
1129 let session_id = self.default_session_id.clone();
1130
1131 let skill_message = format!(
1133 "## Skill: {}\n\n{}\n\n{}",
1134 skill.frontmatter.name,
1135 skill.frontmatter.description,
1136 skill.content
1137 );
1138
1139 let skill_msg = ChatCompletionRequestMessage::System(
1140 ChatCompletionRequestSystemMessage {
1141 content: skill_message.into(),
1142 name: Some(skill.frontmatter.name.clone()),
1143 }
1144 .into(),
1145 );
1146
1147 if let Some(session) = self.sessions.get_mut(&session_id) {
1148 session.history.push(skill_msg);
1149 }
1150
1151 let user_content = if args.is_empty() {
1153 "(User triggered skill, no additional arguments)".to_string()
1154 } else {
1155 args.to_string()
1156 };
1157
1158 if let Some(session) = self.sessions.get_mut(&session_id) {
1159 session.history.push(ChatCompletionRequestMessage::User(
1160 ChatCompletionRequestUserMessage {
1161 content: user_content.into(),
1162 name: None,
1163 }
1164 .into(),
1165 ));
1166 }
1167
1168 let max_iterations = 20;
1170 let mut completed = false;
1171 for iteration in 0..max_iterations {
1172 match self.run_one_step(&session_id).await {
1173 Ok(tool_call_count) => {
1174 if tool_call_count == 0 {
1175 completed = true;
1176 break;
1177 }
1178 tracing::debug!(
1179 "Skill iteration {}: tool calls executed",
1180 iteration
1181 );
1182 }
1183 Err(e) => {
1184 let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
1185 break;
1186 }
1187 }
1188 }
1189
1190 if !completed {
1191 let _ = self
1192 .frontend
1193 .on_event(AgentEvent::Error(AgentError::InternalError(
1194 format!("Max iterations reached ({})", max_iterations),
1195 )))
1196 .await;
1197 }
1198
1199 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
1200
1201 if let Some(session) = self.sessions.get_mut(&session_id) {
1203 let skill_name = skill.frontmatter.name.clone();
1204 session.history.retain(|msg| {
1205 !matches!(
1206 msg,
1207 ChatCompletionRequestMessage::System(s)
1208 if s.name.as_deref() == Some(&skill_name)
1209 )
1210 });
1211 }
1212 }
1213
1214 async fn handle_async_done(&mut self, done: AsyncTaskDone) {
1217 tracing::info!(
1218 "[async] task done: task_id={}, tool={}, session={}, cancelled={}, is_error={}",
1219 done.task_id,
1220 done.tool_name,
1221 done.session_id,
1222 done.cancelled,
1223 done.result.is_error
1224 );
1225
1226 self.pending_tasks.remove(&done.task_id);
1228
1229 let status = if done.cancelled {
1231 AsyncTaskStatus::Cancelled
1232 } else if done.result.is_error {
1233 AsyncTaskStatus::Failed
1234 } else {
1235 AsyncTaskStatus::Completed
1236 };
1237 let summary = summarize_result(&done.result.content);
1238 self.task_registry
1239 .update(&done.task_id, status, Some(summary));
1240
1241 let _ = self
1244 .frontend
1245 .on_event(AgentEvent::AsyncToolCompleted {
1246 task_id: done.task_id.clone(),
1247 tool_call_id: done.tool_call_id.clone(),
1248 result: done.result.clone(),
1249 })
1250 .await;
1251
1252 let session_id = done.session_id.clone();
1256 if !self.sessions.contains_key(&session_id) {
1257 tracing::error!(
1258 "[async] task {} (tool={}) finished but session {} not found; dropping result. \
1259 This means the Agent exited or the session was cleaned up before the task completed. \
1260 Result: {} chars, is_error={}, cancelled={}",
1261 done.task_id, done.tool_name, session_id,
1262 done.result.content.len(), done.result.is_error, done.cancelled
1263 );
1264 return;
1265 }
1266
1267 let notice = format!(
1271 "[后台任务完成通知] task_id={} (工具: {})\n{}",
1272 done.task_id, done.tool_name, done.result.content
1273 );
1274 if let Some(session) = self.sessions.get_mut(&session_id) {
1275 session.history.push(ChatCompletionRequestMessage::User(
1276 ChatCompletionRequestUserMessage {
1277 content: notice.into(),
1278 name: None,
1279 },
1280 ));
1281
1282 if self.llm_client.supports_images() {
1285 if let Some(image_msg) = build_image_user_message(&done.result.images) {
1286 session.history.push(image_msg);
1287 }
1288 }
1289 }
1290
1291 self.run_agent_loop(&session_id).await;
1293 }
1294
1295 async fn handle_cancel_task(&mut self, task_id: &str) {
1298 match self.pending_tasks.remove(task_id) {
1299 Some(pending) => {
1300 tracing::info!(
1301 "[async] cancelling task {} (tool={})",
1302 task_id,
1303 pending.tool_name
1304 );
1305 pending.cancel.cancel();
1306 }
1307 None => {
1308 tracing::warn!("[async] cancel request for unknown task {}", task_id);
1309 }
1310 }
1311 }
1312
1313 async fn handle_cancel_all(&mut self) {
1315 let count = self.pending_tasks.len();
1316 if count == 0 {
1317 tracing::info!("[async] Cancel requested, no pending tasks");
1318 return;
1319 }
1320 tracing::info!("[async] cancelling all {} pending task(s)", count);
1321 for (_, pending) in self.pending_tasks.drain() {
1322 pending.cancel.cancel();
1323 }
1324 }
1325}
1326
1327impl Drop for Agent {
1328 fn drop(&mut self) {
1329 let count = self.pending_tasks.len();
1332 if count > 0 {
1333 tracing::info!(
1334 "[async] Agent dropped, cancelling {} pending task(s)",
1335 count
1336 );
1337 for (_, pending) in self.pending_tasks.drain() {
1338 pending.cancel.cancel();
1339 }
1340 }
1341 }
1342}
1343
1344async fn apply_compression_result(
1356 llm_client: &LlmClient,
1357 history: &mut [ChatCompletionRequestMessage],
1358 result: &TruncationResult,
1359) {
1360 if !result.needs_compression {
1361 return;
1362 }
1363
1364 let pos = result.insert_position;
1365 if pos >= history.len() {
1366 tracing::warn!("Insert position {} out of bounds (history len: {})", pos, history.len());
1367 return;
1368 }
1369
1370 let (content, name) = match &result.action {
1371 TruncationAction::NewSegment => {
1372 let summary = generate_summary(llm_client, &result.removed_messages).await;
1373 (
1374 format!("[Summary: {}]", summary),
1375 "summary_segment".to_string(),
1376 )
1377 }
1378 TruncationAction::MergeSegments { summaries, .. } => {
1379 let merged = merge_summaries(llm_client, summaries).await;
1380 let current_level = crate::context::get_merge_level(&history[pos]);
1382 let name = if current_level == 0 {
1383 "summary_segment".to_string()
1384 } else {
1385 format!("summary_segment_m{}", current_level)
1386 };
1387 (format!("[Summary: {}]", merged), name)
1388 }
1389 TruncationAction::TruncateOnly => return,
1390 };
1391
1392 tracing::info!("Compression applied at position {}: {}", pos, name);
1393
1394 history[pos] = ChatCompletionRequestMessage::User(
1395 ChatCompletionRequestUserMessage {
1396 content: content.into(),
1397 name: Some(name),
1398 }
1399 );
1400}
1401
1402async fn generate_summary(
1404 llm_client: &LlmClient,
1405 removed_messages: &[ChatCompletionRequestMessage],
1406) -> String {
1407 tracing::debug!("Generating summary: removed_messages count = {}", removed_messages.len());
1408 let transcript = crate::context::format_removed_messages_as_transcript(removed_messages);
1409 tracing::debug!("Formatted transcript length: {} characters", transcript.len());
1410
1411 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.";
1412
1413 let messages = vec![
1414 ChatCompletionRequestMessage::System(
1415 ChatCompletionRequestSystemMessage {
1416 content: system_prompt.into(),
1417 name: None,
1418 }
1419 ),
1420 ChatCompletionRequestMessage::User(
1421 ChatCompletionRequestUserMessage {
1422 content: format!("Conversation transcript:\n\n{}", transcript).into(),
1423 name: None,
1424 }
1425 ),
1426 ];
1427
1428 tracing::info!("Calling LLM to generate summary...");
1429 match llm_client.chat(messages, None).await {
1430 Ok(response) => {
1431 tracing::info!("LLM responded successfully for summary generation");
1432 tracing::debug!("Number of choices in response: {}", response.choices.len());
1433 if let Some(choice) = response.choices.first() {
1434 tracing::debug!("Choice index: 0, has content: {}", choice.message.content.is_some());
1435 if let Some(content) = &choice.message.content {
1436 let summary = content.trim().to_string();
1437 if !summary.is_empty() {
1438 tracing::info!("Successfully generated summary (length: {})", summary.len());
1439 return summary;
1440 }
1441 }
1442 }
1443 tracing::warn!("Summary generation returned empty response, using fallback");
1444 "Conversation history compressed.".to_string()
1445 }
1446 Err(e) => {
1447 tracing::error!("Summary generation failed with error: {}, using fallback", e);
1448 "Conversation history compressed.".to_string()
1449 }
1450 }
1451}
1452
1453async fn merge_summaries(
1455 llm_client: &LlmClient,
1456 summaries: &[String],
1457) -> String {
1458 tracing::info!("Merging {} summary segments...", summaries.len());
1459
1460 let numbered: Vec<String> = summaries
1461 .iter()
1462 .enumerate()
1463 .map(|(i, s)| format!("[{}] {}", i + 1, s))
1464 .collect();
1465 let joined = numbered.join("\n\n");
1466
1467 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.
1468
1469Key points to preserve:
1470- User goals and requests
1471- Important decisions made
1472- Technical context (file paths, APIs, architectures)
1473- Major outcomes and conclusions
1474
1475Do not simply concatenate — synthesize into a coherent narrative.";
1476
1477 let messages = vec![
1478 ChatCompletionRequestMessage::System(
1479 ChatCompletionRequestSystemMessage {
1480 content: system_prompt.into(),
1481 name: None,
1482 }
1483 ),
1484 ChatCompletionRequestMessage::User(
1485 ChatCompletionRequestUserMessage {
1486 content: format!("Summaries to merge:\n\n{}", joined).into(),
1487 name: None,
1488 }
1489 ),
1490 ];
1491
1492 match llm_client.chat(messages, None).await {
1493 Ok(response) => {
1494 if let Some(choice) = response.choices.first() {
1495 if let Some(content) = &choice.message.content {
1496 let summary = content.trim().to_string();
1497 if !summary.is_empty() {
1498 tracing::info!("Successfully merged {} summaries (length: {})", summaries.len(), summary.len());
1499 return summary;
1500 }
1501 }
1502 }
1503 tracing::warn!("Summary merge returned empty response, using fallback");
1504 "Multiple earlier conversation segments merged.".to_string()
1505 }
1506 Err(e) => {
1507 tracing::error!("Summary merge failed with error: {}, using fallback", e);
1508 "Multiple earlier conversation segments merged.".to_string()
1509 }
1510 }
1511}
1512
1513#[derive(Debug)]
1519struct ToolCallAccumulator {
1520 id: Option<String>,
1521 name: Option<String>,
1522 arguments: String,
1523}
1524
1525impl ToolCallAccumulator {
1526 fn new() -> Self {
1527 Self {
1528 id: None,
1529 name: None,
1530 arguments: String::new(),
1531 }
1532 }
1533
1534 fn into_tool_call(self) -> Option<ChatCompletionMessageToolCall> {
1536 let id = self.id?;
1537 let name = self.name?;
1538
1539 tracing::trace!(
1540 "Tool call assembled: id='{}', name='{}', args={}",
1541 id,
1542 name,
1543 truncate_for_log(&self.arguments, 80)
1544 );
1545
1546 Some(ChatCompletionMessageToolCall {
1547 id,
1548 function: FunctionCall {
1549 name,
1550 arguments: self.arguments,
1551 },
1552 })
1553 }
1554}
1555
1556fn truncate_for_log(s: &str, max_chars: usize) -> String {
1560 let char_count = s.chars().count();
1561 if char_count <= max_chars {
1562 s.to_string()
1563 } else {
1564 let preview: String = s.chars().take(max_chars).collect();
1565 format!("{}... ({} chars total)", preview, char_count)
1566 }
1567}
1568
1569fn build_image_user_message(images: &[ToolImage]) -> Option<ChatCompletionRequestMessage> {
1573 if images.is_empty() {
1574 return None;
1575 }
1576 let mut parts = vec![ChatCompletionRequestUserMessageContentPart::Text(
1577 ChatCompletionRequestMessageContentPartText {
1578 text: format!(
1579 "[工具返回的图片] {}",
1580 images
1581 .iter()
1582 .map(|i| i.label.as_str())
1583 .collect::<Vec<_>>()
1584 .join(", ")
1585 ),
1586 },
1587 )];
1588 for img in images {
1589 parts.push(ChatCompletionRequestUserMessageContentPart::ImageUrl(
1590 ChatCompletionRequestMessageContentPartImage {
1591 image_url: ImageUrl {
1592 url: img.data_url.clone(),
1593 detail: None,
1594 },
1595 },
1596 ));
1597 }
1598 Some(ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
1599 content: ChatCompletionRequestUserMessageContent::Array(parts),
1600 name: None,
1601 }))
1602}
1603
1604fn sanitize_history_for_model(
1612 history: &mut Vec<ChatCompletionRequestMessage>,
1613 supports_images: bool,
1614) {
1615 if supports_images {
1616 return;
1617 }
1618
1619 let mut sanitized_count = 0usize;
1620 for msg in history.iter_mut() {
1621 if let ChatCompletionRequestMessage::User(user_msg) = msg {
1622 if let ChatCompletionRequestUserMessageContent::Array(parts) = &user_msg.content {
1623 let has_image = parts
1625 .iter()
1626 .any(|p| matches!(p, ChatCompletionRequestUserMessageContentPart::ImageUrl(_)));
1627 if has_image {
1628 let text: String = parts
1630 .iter()
1631 .filter_map(|p| {
1632 if let ChatCompletionRequestUserMessageContentPart::Text(t) = p {
1633 Some(t.text.as_str())
1634 } else {
1635 None
1636 }
1637 })
1638 .collect::<Vec<_>>()
1639 .join("\n");
1640
1641 user_msg.content = ChatCompletionRequestUserMessageContent::Text(text);
1642 sanitized_count += 1;
1643 }
1644 }
1645 }
1646 }
1647
1648 if sanitized_count > 0 {
1649 tracing::info!(
1650 "sanitize_history_for_model: downgraded {} message(s) with image_url to text \
1651 (model does not support images)",
1652 sanitized_count
1653 );
1654 }
1655}
1656
1657fn summarize_result(content: &str) -> String {
1659 const MAX: usize = 500;
1660 let char_count = content.chars().count();
1661 if char_count <= MAX {
1662 content.to_string()
1663 } else {
1664 let truncated: String = content.chars().take(MAX).collect();
1665 format!("{}... (truncated, {} chars total)", truncated, char_count)
1666 }
1667}