Skip to main content

robit_agent/
agent.rs

1//! Agent — the event-driven loop that orchestrates LLM calls and tool execution.
2
3use 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
14// Import ImageUrl from wherever it is in async-openai 0.41
15use 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
37// ============================================================================
38// AgentSession
39// ============================================================================
40
41/// A single conversation session with its own message history.
42pub struct AgentSession {
43    pub session_id: SessionId,
44    pub history: Vec<ChatCompletionRequestMessage>,
45    pub working_dir: PathBuf,
46    /// The last known exact prompt token count from API `usage.prompt_tokens`.
47    /// Used as a calibration anchor: `last_known_prompt_tokens` is the precise
48    /// token count for the first `snapshot_message_count` messages in history.
49    /// New messages appended after the snapshot are estimated incrementally.
50    /// Invalidated (set to `None`) when history is truncated/compressed.
51    pub last_known_prompt_tokens: Option<u32>,
52    /// Number of messages in `history` that `last_known_prompt_tokens` covers.
53    /// If `history.len() < snapshot_message_count`, the snapshot is stale
54    /// (truncation/compression happened) and calibration is invalid.
55    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    /// Create session with pre-loaded history
78    pub fn with_history(
79        session_id: SessionId,
80        working_dir: PathBuf,
81        system_prompt: String,
82        history: Vec<ChatCompletionRequestMessage>,
83    ) -> Self {
84        // Create system message (new one with latest config)
85        let system_msg = ChatCompletionRequestMessage::System(
86            ChatCompletionRequestSystemMessage {
87                content: system_prompt.into(),
88                name: None,
89            }
90            .into(),
91        );
92
93        // Prepend new system message to history
94        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
107// ============================================================================
108// Agent
109// ============================================================================
110
111/// The Agent orchestrates LLM calls and tool execution.
112pub 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    /// Platform-specific extensions passed to ToolContext during tool execution.
122    extensions: HashMap<String, Arc<dyn Any + Send + Sync>>,
123    /// Pending truncation result that needs compression (handled at start of run loop).
124    pending_truncation: Option<(SessionId, crate::context::TruncationResult)>,
125    /// Handle for tools to submit async background work. Cloned into every
126    /// ToolContext; the matching `done_rx` is drained in `run()`.
127    async_runner: AsyncTaskRunner,
128    /// Receiver for completed async tasks. `Option` so `run()` can `take()` it
129    /// into a local, avoiding borrowing `self` across the `select!` loop body.
130    done_rx: Option<mpsc::Receiver<AsyncTaskDone>>,
131    /// In-flight async tasks keyed by task_id, with their cancel tokens.
132    pending_tasks: HashMap<String, PendingTask>,
133    /// Shared registry of async task statuses, read by the `query_task` tool
134    /// via ToolContext. Cloned per ToolContext (shared `Arc`).
135    task_registry: TaskRegistry,
136}
137
138/// Bookkeeping for one in-flight async task.
139struct PendingTask {
140    cancel: CancellationToken,
141    tool_name: String,
142}
143
144impl Agent {
145    /// Create a new Agent with the given dependencies.
146    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        // Build system prompt with skills. Tools are NOT listed in the prompt;
161        // they are exposed via the function-calling `tools` request parameter.
162        let skill_descs = skills.skill_descriptions();
163        let system_prompt = prompt_builder.build_system_prompt(&skill_descs, &working_dir);
164
165        // Create default session
166        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    /// Create Agent with pre-loaded history (for resuming sessions)
195    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        // Build system prompt with skills. Tools are NOT listed in the prompt;
217        // they are exposed via the function-calling `tools` request parameter.
218        let skill_descs = skills.skill_descriptions();
219        let system_prompt = prompt_builder.build_system_prompt(&skill_descs, &working_dir);
220
221        // Create session with history
222        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        // Sanitize history: remove image_url parts if the model doesn't support images.
234        // This prevents 400 errors from APIs that only accept text content.
235        let supports_images = llm_client.supports_images();
236        sanitize_history_for_model(&mut session.history, supports_images);
237        // Apply context truncation before starting
238        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    /// Run the agent's main event loop. Takes ownership of the message receiver.
288    /// Returns when the channel is closed or user types /exit.
289    pub async fn run(mut self, mut message_rx: mpsc::Receiver<FrontendMessage>) {
290        tracing::info!("Agent started, session: {}", self.default_session_id);
291
292        // Handle pending compression from with_history initialization.
293        // May need multiple compression rounds for long histories.
294        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                // Take one pending result, if any
302                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                // Apply the compression result (generate summary / merge)
318                if let Some(session) = self.sessions.get_mut(&session_id) {
319                    apply_compression_result(&self.llm_client, &mut session.history, &result).await;
320                    // Compression changed history — invalidate calibration
321                    session.last_known_prompt_tokens = None;
322                    session.snapshot_message_count = 0;
323                }
324
325                // Check if more compression is needed
326                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                // Do another round of truncation
343                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                        // Truncation happened but no compression needed (e.g. discard)
353                        tracing::info!("Truncation without compression: {} messages removed", next_result.messages_removed);
354                        // Continue the loop to check if still over threshold
355                        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        // Take done_rx out of self so the select! loop can borrow it without
368        // borrowing all of `self` (which would conflict with the &mut self
369        // calls made inside the message branch).
370        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                            // Check for skill trigger
397                            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                            // Cancel all in-flight async tasks for this Agent.
407                            self.handle_cancel_all().await;
408                        }
409                        FrontendMessage::CancelTask { task_id } => {
410                            self.handle_cancel_task(&task_id).await;
411                        }
412                        FrontendMessage::ConfirmationResponse { .. } => {
413                            // Confirmation is handled via frontend.request_tool_confirmation()
414                            // within run_one_step. This variant is reserved for future use.
415                            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        // ── Drain phase: cancel pending async tasks and collect their results ──
427        // Without this, `done_rx` is dropped when `self` goes out of scope,
428        // causing any in-flight async tasks (e.g. image generation) to have
429        // their results silently lost. We cancel first (fast), then give
430        // tasks a brief window to deliver their final status through done_rx.
431        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            // Cancel all pending tasks so they finish quickly.
438            for (_, pending) in self.pending_tasks.drain() {
439                pending.cancel.cancel();
440            }
441            // Collect results from the done channel. Each task should respond
442            // to cancellation within seconds; use a bounded window per task.
443            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                        // All senders dropped — no more results coming.
461                        tracing::debug!("[async] done_tx closed during drain");
462                        break;
463                    }
464                    Err(_) => {
465                        // Per-iteration timeout — loop back to check deadline.
466                    }
467                }
468            }
469            tracing::info!("[async] drain phase complete");
470        }
471
472        tracing::info!("Agent stopped");
473    }
474
475    /// Execute a single turn: user input -> LLM call(s) -> tool execution(s) -> response.
476    async fn run_turn(&mut self, user_input: &str, attachments: Vec<MediaAttachment>) {
477        let session_id = self.default_session_id.clone();
478
479        // Build user message first (to avoid borrow conflict)
480        let user_message = self.build_user_message(user_input, &attachments).await;
481
482        // Add user message to history
483        if let Some(session) = self.sessions.get_mut(&session_id) {
484            session.history.push(user_message);
485        }
486
487        // Run the agentic loop (may iterate if LLM calls tools).
488        self.run_agent_loop(&session_id).await;
489    }
490
491    /// Run the agentic loop: call LLM, execute tools, repeat until the LLM
492    /// produces a final response (no tool calls) or a safety limit is hit.
493    /// Shared by user-input turns and async-task-completion reinjection.
494    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                    // Check against per-turn tool call limit
508                    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        // Safety limit
544        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    /// Run one step: call LLM, process response, execute tools.
554    /// Returns the number of tool calls executed (0 = turn complete, no tools called).
555    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        // Truncate context if needed
562        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        // Handle compression: generate actual summary / merge via LLM
569        if truncation_result.needs_compression {
570            apply_compression_result(&self.llm_client, &mut session.history, &truncation_result).await;
571            // Truncation changed history — invalidate calibration
572            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            // Messages were removed (e.g. discard) — also invalidate calibration
581            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        // Build tool schemas. Only sent when the model supports function
591        // calling (`supports_tools = true` in config): OpenAI-compatible
592        // providers that don't understand the `tools` parameter reject the
593        // whole request, and models without tool support can't act on them.
594        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        // Log estimated token usage before call (uses calibrated estimation when available)
606        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        // Sanitize history right before API call — some providers reject
624        // image_url content even when supports_images was checked earlier
625        // (e.g. model switch mid-session, or stale history from DB).
626        if !self.llm_client.supports_images() {
627            sanitize_history_for_model(&mut session.history, false);
628        }
629
630        // Call LLM (streaming)
631        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        // Collect streaming response
645        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            // Capture usage info if present in this chunk (some providers include it in final chunk)
661            if let Some(ref usage) = chunk.usage {
662                api_usage = Some(usage.clone());
663            }
664
665            if let Some(choice) = chunk.choices.first() {
666                // Text content
667                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                // Tool call deltas
676                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                            // 只有当id非空时才更新
684                            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                                // 只有当name非空时才更新
691                                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        // Assemble complete tool calls from chunks
707        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        // Log token usage summary
717        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        // Calibrate token estimation: store the API-reported prompt_tokens as a
738        // precise baseline. At this point session.history still reflects exactly
739        // what was sent to the API (assistant_msg hasn't been pushed yet), so
740        // prompt_tokens is the exact token count for session.history.
741        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        // Add assistant message to history
751        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        // Ensure we don't add an invalid assistant message to history
769        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 no tool calls, turn is complete
789        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    /// Execute the tool calls assembled from one LLM response and append the
797    /// results to the session history.
798    async fn execute_tool_calls(
799        &mut self,
800        session_id: &SessionId,
801        assembled_tool_calls: &[ChatCompletionMessageToolCall],
802    ) -> Result<usize> {
803        // First get the working_dir before any mutable borrow of sessions
804        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        // Images collected across the whole batch, injected as one user
813        // message after the loop.
814        let mut batch_images: Vec<ToolImage> = Vec::new();
815
816        // Execute each tool call
817        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            // Notify frontend. Capture the result instead of `let _ =` so a
834            // failed delivery (closed/full channel, platform send error) is
835            // surfaced — a silent failure here is exactly the "no feedback"
836            // symptom we want to catch.
837            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            // Check confirmation
855            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            // Execute or reject
892            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                // Per-call cancellation token. Async tools pass a clone into
897                // `async_runner.submit`; if the tool goes async the Agent keeps
898                // this clone in `pending_tasks` so it can cancel the work later.
899                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 the tool went async, register the task so it can be
924                // tracked and cancelled. The placeholder content is still added
925                // to history below (as the tool message) so the LLM can keep
926                // working while the task runs.
927                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            // Truncate output
970            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            // Notify frontend of result. Same rationale as above: capture
989            // delivery errors so a lost ToolCallResult is never silent.
990            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            // Add tool result to history
1007            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            // Collect images from this tool result; they are injected after
1022            // ALL tool messages of the batch (below).
1023            batch_images.extend(truncated_result.images);
1024        }
1025
1026        // Inject the batch's collected images as a single multimodal user
1027        // message AFTER all tool messages. OpenAI protocol restricts tool
1028        // message content to text, so images travel in a separate user
1029        // message — but it must not interleave with the tool responses:
1030        // providers reject anything between an assistant `tool_calls`
1031        // message and its tool responses with a 400 error.
1032        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    /// Clear the current session's history (keep system prompt).
1046    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    /// Build a user message, potentially with images if model supports them.
1053    async fn build_user_message(
1054        &self,
1055        text: &str,
1056        attachments: &[MediaAttachment],
1057    ) -> ChatCompletionRequestMessage {
1058        // If model supports images and we have image attachments, build multimodal message
1059        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            // Fallback: add attachment descriptions to text
1067            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    /// Build a multimodal message with text + images.
1079    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        // Add images
1092        for attachment in attachments {
1093            if attachment.is_image() {
1094                // Download and encode as base64
1095                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                        // Fallback to description
1115                        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                // Non-image: add description
1125                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    /// Execute a skill-triggered turn: inject skill content, then run the agent loop.
1141    ///
1142    /// The skill's full content is injected as a temporary system message and removed
1143    /// after the turn completes, so it doesn't occupy context in future turns.
1144    async fn run_skill_turn(&mut self, skill: &crate::skill::Skill, args: &str) {
1145        // Notify frontend
1146        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        // Inject skill content as a system message
1157        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        // Add user message (args or default)
1177        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        // Run the agentic loop
1194        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        // Remove the injected skill system message to avoid polluting future turns
1227        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    /// Handle a completed async background task: update tracking, notify the
1240    /// frontend, reinject the result into history, and wake the LLM.
1241    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        // No longer in flight.
1252        self.pending_tasks.remove(&done.task_id);
1253
1254        // Update the registry with final status + a result summary.
1255        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        // Notify the frontend (TUI/GUI update task panels; chatbot usually
1267        // relies on the subsequent LLM reply delivered via TextDelta).
1268        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        // Reinject into the owning session. If the session is gone (e.g. the
1278        // chatbot expired this Agent), drop the result - side effects like
1279        // saved files already happened.
1280        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        // Append a user-role notification. We do NOT mutate the original
1293        // placeholder tool message: the LLM may have already acted on it, and
1294        // rewriting history would break consistency.
1295        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            // Inject result images as a multimodal user message (same pattern
1308            // as sync tool results).
1309            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        // Wake the LLM to process the notification.
1317        self.run_agent_loop(&session_id).await;
1318    }
1319
1320    /// Cancel a specific async task by id. The spawned task emits a cancelled
1321    /// `done` which flows through `handle_async_done` to update status.
1322    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    /// Cancel all in-flight async tasks for this Agent.
1339    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        // Cancel any still-running async tasks so they don't outlive the Agent
1355        // (e.g. when a chatbot session expires and the Agent task is dropped).
1356        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
1369// ============================================================================
1370// Summary generation (free function to avoid borrow conflicts)
1371// ============================================================================
1372
1373/// Generate a summary of removed conversation messages using the LLM.
1374/// Uses a non-streaming call to produce a 1-2 sentence summary.
1375/// Falls back to a static message on failure.
1376/// Apply a truncation result to the session history.
1377/// For NewSegment: generates a summary from removed messages and replaces the placeholder.
1378/// For MergeSegments: merges existing summary segments and replaces the placeholder.
1379/// For TruncateOnly: no-op.
1380async 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            // Determine new merge level from the placeholder's name
1406            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
1427/// Generate a short summary from removed full conversation rounds.
1428async 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
1478/// Merge multiple existing summary segments into one coherent summary.
1479async 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// ============================================================================
1539// Helper types
1540// ============================================================================
1541
1542/// Accumulates streaming tool call chunks.
1543#[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    /// Convert accumulated chunks into a complete tool call.
1560    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
1581/// Truncate a string to at most `max_chars` characters for log output,
1582/// appending a length note when truncated. Counts by `char` to avoid
1583/// splitting multi-byte UTF-8 sequences (safe for CJK text).
1584fn 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
1594/// Build a multimodal user message carrying tool-result images, or `None` if
1595/// there are no images. Shared by sync tool results (`run_one_step`) and async
1596/// task-completion reinjection (`handle_async_done`).
1597fn 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
1631/// Sanitize session history for models that don't support image inputs.
1632///
1633/// When the current model has `supports_images = false`, any `image_url`
1634/// content parts in user messages are incompatible with the API and will
1635/// cause a 400 error. This function downgrades multimodal `Array` content
1636/// to plain `Text` by concatenating the text parts and discarding image
1637/// parts. Non-array (plain text) messages are left unchanged.
1638fn 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                // Check if this message actually contains image parts
1651                let has_image = parts
1652                    .iter()
1653                    .any(|p| matches!(p, ChatCompletionRequestUserMessageContentPart::ImageUrl(_)));
1654                if has_image {
1655                    // Concatenate all text parts, skip image parts
1656                    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
1684/// Truncate a task result to a bounded summary for the task registry.
1685fn 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    /// Frontend that swallows all events (no UI in tests).
1708    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    /// A tool whose result always carries one image, mimicking `read` on an
1722    /// image file with a vision-capable model.
1723    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    /// Build an `LlmClient` for a vision model without contacting it (the
1758    /// base URL points at a closed port; only `supports_images()` is used).
1759    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    /// Regression test for the DeepSeek 400 "insufficient tool messages
1799    /// following tool_calls message": when a parallel tool-call batch returns
1800    /// images, the injected multimodal user message(s) must come AFTER all
1801    /// tool messages of the batch, never between them.
1802    #[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        // History layout: [system, tool, tool, tool, user(images)]. The
1825        // system prompt is message 0; skip it.
1826        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}