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        // First get the working_dir before the first mutable borrow
557        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        // Truncate context if needed
571        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        // Handle compression: generate actual summary / merge via LLM
578        if truncation_result.needs_compression {
579            apply_compression_result(&self.llm_client, &mut session.history, &truncation_result).await;
580            // Truncation changed history — invalidate calibration
581            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            // Messages were removed (e.g. discard) — also invalidate calibration
590            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        // Build tool schemas. Only sent when the model supports function
600        // calling (`supports_tools = true` in config): OpenAI-compatible
601        // providers that don't understand the `tools` parameter reject the
602        // whole request, and models without tool support can't act on them.
603        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        // Log estimated token usage before call (uses calibrated estimation when available)
615        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        // Sanitize history right before API call — some providers reject
633        // image_url content even when supports_images was checked earlier
634        // (e.g. model switch mid-session, or stale history from DB).
635        if !self.llm_client.supports_images() {
636            sanitize_history_for_model(&mut session.history, false);
637        }
638
639        // Call LLM (streaming)
640        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        // Collect streaming response
654        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            // Capture usage info if present in this chunk (some providers include it in final chunk)
670            if let Some(ref usage) = chunk.usage {
671                api_usage = Some(usage.clone());
672            }
673
674            if let Some(choice) = chunk.choices.first() {
675                // Text content
676                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                // Tool call deltas
685                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                            // 只有当id非空时才更新
693                            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                                // 只有当name非空时才更新
700                                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        // Assemble complete tool calls from chunks
716        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        // Log token usage summary
726        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        // Calibrate token estimation: store the API-reported prompt_tokens as a
747        // precise baseline. At this point session.history still reflects exactly
748        // what was sent to the API (assistant_msg hasn't been pushed yet), so
749        // prompt_tokens is the exact token count for session.history.
750        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        // Add assistant message to history
760        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        // Ensure we don't add an invalid assistant message to history
778        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 no tool calls, turn is complete
798        if assembled_tool_calls.is_empty() {
799            return Ok(0);
800        }
801
802        // Execute each tool call
803        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            // Notify frontend. Capture the result instead of `let _ =` so a
820            // failed delivery (closed/full channel, platform send error) is
821            // surfaced — a silent failure here is exactly the "no feedback"
822            // symptom we want to catch.
823            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            // Check confirmation
841            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            // Execute or reject
878            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                // Per-call cancellation token. Async tools pass a clone into
883                // `async_runner.submit`; if the tool goes async the Agent keeps
884                // this clone in `pending_tasks` so it can cancel the work later.
885                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 the tool went async, register the task so it can be
910                // tracked and cancelled. The placeholder content is still added
911                // to history below (as the tool message) so the LLM can keep
912                // working while the task runs.
913                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            // Truncate output
956            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            // Notify frontend of result. Same rationale as above: capture
975            // delivery errors so a lost ToolCallResult is never silent.
976            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            // Add tool result to history
993            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            // Inject images from the tool result as a multimodal user message.
1009            // OpenAI protocol restricts tool message content to text, so images
1010            // cannot travel in the tool result itself; we inject them in a
1011            // separate user message right after the tool result.
1012            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    /// Clear the current session's history (keep system prompt).
1023    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    /// Build a user message, potentially with images if model supports them.
1030    async fn build_user_message(
1031        &self,
1032        text: &str,
1033        attachments: &[MediaAttachment],
1034    ) -> ChatCompletionRequestMessage {
1035        // If model supports images and we have image attachments, build multimodal message
1036        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            // Fallback: add attachment descriptions to text
1044            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    /// Build a multimodal message with text + images.
1056    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        // Add images
1068        for attachment in attachments {
1069            if attachment.is_image() {
1070                // Download and encode as base64
1071                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                        // Fallback to description
1090                        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                // Non-image: add description
1100                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    /// Execute a skill-triggered turn: inject skill content, then run the agent loop.
1116    ///
1117    /// The skill's full content is injected as a temporary system message and removed
1118    /// after the turn completes, so it doesn't occupy context in future turns.
1119    async fn run_skill_turn(&mut self, skill: &crate::skill::Skill, args: &str) {
1120        // Notify frontend
1121        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        // Inject skill content as a system message
1132        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        // Add user message (args or default)
1152        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        // Run the agentic loop
1169        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        // Remove the injected skill system message to avoid polluting future turns
1202        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    /// Handle a completed async background task: update tracking, notify the
1215    /// frontend, reinject the result into history, and wake the LLM.
1216    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        // No longer in flight.
1227        self.pending_tasks.remove(&done.task_id);
1228
1229        // Update the registry with final status + a result summary.
1230        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        // Notify the frontend (TUI/GUI update task panels; chatbot usually
1242        // relies on the subsequent LLM reply delivered via TextDelta).
1243        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        // Reinject into the owning session. If the session is gone (e.g. the
1253        // chatbot expired this Agent), drop the result - side effects like
1254        // saved files already happened.
1255        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        // Append a user-role notification. We do NOT mutate the original
1268        // placeholder tool message: the LLM may have already acted on it, and
1269        // rewriting history would break consistency.
1270        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            // Inject result images as a multimodal user message (same pattern
1283            // as sync tool results).
1284            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        // Wake the LLM to process the notification.
1292        self.run_agent_loop(&session_id).await;
1293    }
1294
1295    /// Cancel a specific async task by id. The spawned task emits a cancelled
1296    /// `done` which flows through `handle_async_done` to update status.
1297    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    /// Cancel all in-flight async tasks for this Agent.
1314    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        // Cancel any still-running async tasks so they don't outlive the Agent
1330        // (e.g. when a chatbot session expires and the Agent task is dropped).
1331        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
1344// ============================================================================
1345// Summary generation (free function to avoid borrow conflicts)
1346// ============================================================================
1347
1348/// Generate a summary of removed conversation messages using the LLM.
1349/// Uses a non-streaming call to produce a 1-2 sentence summary.
1350/// Falls back to a static message on failure.
1351/// Apply a truncation result to the session history.
1352/// For NewSegment: generates a summary from removed messages and replaces the placeholder.
1353/// For MergeSegments: merges existing summary segments and replaces the placeholder.
1354/// For TruncateOnly: no-op.
1355async 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            // Determine new merge level from the placeholder's name
1381            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
1402/// Generate a short summary from removed full conversation rounds.
1403async 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
1453/// Merge multiple existing summary segments into one coherent summary.
1454async 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// ============================================================================
1514// Helper types
1515// ============================================================================
1516
1517/// Accumulates streaming tool call chunks.
1518#[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    /// Convert accumulated chunks into a complete tool call.
1535    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
1556/// Truncate a string to at most `max_chars` characters for log output,
1557/// appending a length note when truncated. Counts by `char` to avoid
1558/// splitting multi-byte UTF-8 sequences (safe for CJK text).
1559fn 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
1569/// Build a multimodal user message carrying tool-result images, or `None` if
1570/// there are no images. Shared by sync tool results (`run_one_step`) and async
1571/// task-completion reinjection (`handle_async_done`).
1572fn 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
1604/// Sanitize session history for models that don't support image inputs.
1605///
1606/// When the current model has `supports_images = false`, any `image_url`
1607/// content parts in user messages are incompatible with the API and will
1608/// cause a 400 error. This function downgrades multimodal `Array` content
1609/// to plain `Text` by concatenating the text parts and discarding image
1610/// parts. Non-array (plain text) messages are left unchanged.
1611fn 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                // Check if this message actually contains image parts
1624                let has_image = parts
1625                    .iter()
1626                    .any(|p| matches!(p, ChatCompletionRequestUserMessageContentPart::ImageUrl(_)));
1627                if has_image {
1628                    // Concatenate all text parts, skip image parts
1629                    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
1657/// Truncate a task result to a bounded summary for the task registry.
1658fn 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}