use crate::agent::context_compaction::{
    context_request_digest, estimate_tokens, is_context_overflow_error,
    parse_context_limit_from_error,
};
use crate::agent::media_handler::MediaHandlerRegistry;
use crate::agent::steering::{SteeringDrain, TurnInput};
use crate::agent::system_prompt::build_system_prompt;
use crate::agent::turn_event::{AgentTurnContext, TurnEvent};
use crate::bus::message::ContentBlock;
use crate::bus::{ChatMessage, MediaRef};
use crate::config::LLMProviderConfig;
use crate::observability::{Observer, ObserverEvent, ToolExecutionOutcome, truncate_args};
use crate::providers::{
    ChatCompletionRequest, ChatCompletionResponse, LLMProvider, Message, ProviderChunk,
    ProviderResponseAccumulator, ToolCall, create_provider,
};
use crate::tools::{ToolExecutionContext, ToolOutputProcessor, ToolRegistry};
use std::collections::VecDeque;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;

use futures_util::StreamExt;

/// Maximum characters in a tool result before truncation.
/// Prevents context overflow from large tool outputs.
const MAX_TOOL_RESULT_CHARS: usize = 16_000;
/// Minimum characters to keep when truncating
const TRUNCATION_SUFFIX_LEN: usize = 200;
const TOOL_PREVIEW_CHARS: usize = 1_000;

enum MediaOrigin<'a> {
    User,
    Tool(&'a str),
    Message,
}

fn should_include_message_media(messages: &[ChatMessage], index: usize) -> bool {
    let message = &messages[index];
    if message.role != "tool" {
        return true;
    }

    // Tool media is replayed only for the most recent tool-call batch.  A
    // steering message is a real `user` message and therefore breaks the
    // contiguous `assistant(tool_calls), tool...` shape used by older code.
    // Find the latest assistant tool-call declaration instead, then keep its
    // tool results eligible until the next assistant message.  This keeps a
    // screenshot returned by a tool visible when the user steers immediately
    // after that tool batch.
    let Some(tool_call_start) = messages.iter().rposition(|candidate| {
        candidate.role == "assistant"
            && candidate
                .tool_calls
                .as_ref()
                .is_some_and(|calls| !calls.is_empty())
    }) else {
        return false;
    };
    if index <= tool_call_start {
        return false;
    }
    let no_assistant_before = !messages[tool_call_start + 1..index]
        .iter()
        .any(|candidate| candidate.role == "assistant");
    let no_assistant_after = !messages[index + 1..]
        .iter()
        .any(|candidate| candidate.role == "assistant");
    no_assistant_before && no_assistant_after
}

/// Build content blocks from text and media, respecting model input capabilities
fn build_content_blocks(
    text: &str,
    media_refs: &[MediaRef],
    input_types: &[String],
    registry: &MediaHandlerRegistry,
    origin: MediaOrigin<'_>,
) -> Vec<ContentBlock> {
    let mut blocks = Vec::new();

    if !media_refs.is_empty() {
        let attachments = media_refs
            .iter()
            .map(|media_ref| {
                let path = std::path::Path::new(&media_ref.path);
                let name = path
                    .file_name()
                    .map(|name| name.to_string_lossy().into_owned())
                    .unwrap_or_else(|| media_ref.path.clone());
                let extension = path
                    .extension()
                    .map(|extension| extension.to_string_lossy().into_owned());
                let mime_type = mime_guess::from_path(path)
                    .first_or_octet_stream()
                    .to_string();
                let size_bytes = std::fs::metadata(path).ok().map(|metadata| metadata.len());
                let native_input = input_types.contains(&media_ref.media_type)
                    && registry.supports(&media_ref.media_type);
                serde_json::json!({
                    "name": name,
                    "extension": extension,
                    "media_type": media_ref.media_type,
                    "mime_type": mime_type,
                    "size_bytes": size_bytes,
                    "path": media_ref.path,
                    "content_delivery": if native_input {
                        "also included as a model-native content block"
                    } else {
                        "content is not embedded in this model request; path remains available to file tools"
                    },
                })
            })
            .collect::<Vec<_>>();
        let manifest = serde_json::Value::Array(attachments).to_string();
        let note = match origin {
            MediaOrigin::User if text.is_empty() => {
                "用户发送了以下附件。path 是 Gateway 内部存储路径，可供文件工具读取；附件内容未必已嵌入模型输入。".to_string()
            }
            MediaOrigin::User => {
                "随本条用户消息同时提交的附件。path 是 Gateway 内部存储路径，可供文件工具读取；content_delivery 说明附件内容是否另以模型原生内容块提供。".to_string()
            }
            MediaOrigin::Tool(tool_name) => format!(
                "工具 {tool_name} 返回了以下附件。content_delivery 说明附件内容是否另以模型原生内容块提供。"
            ),
            MediaOrigin::Message => {
                "本条消息包含以下附件。content_delivery 说明附件内容是否另以模型原生内容块提供。".to_string()
            }
        };
        let message_text = if text.is_empty() {
            format!("[{note}]\n{manifest}")
        } else {
            format!("{text}\n\n[{note}]\n{manifest}")
        };
        blocks.push(ContentBlock::text(message_text));

        for mr in media_refs {
            if input_types.contains(&mr.media_type) && registry.supports(&mr.media_type) {
                match registry.handle(&mr.media_type, &mr.path) {
                    Ok(content_blocks) => blocks.extend(content_blocks),
                    Err(e) => {
                        tracing::warn!(
                            path = %mr.path,
                            media_type = %mr.media_type,
                            error = %e,
                            "Media handler failed, falling back to text placeholder"
                        );
                        blocks.push(ContentBlock::text(format!(
                            "[用户发来了一个文件，但处理失败: {}, 错误: {}]",
                            mr.path, e
                        )));
                    }
                }
            } else {
                tracing::debug!(
                    path = %mr.path,
                    media_type = %mr.media_type,
                    model_input_types = ?input_types,
                    "Media type not supported by model; attachment manifest remains available"
                );
            }
        }
    } else if !text.is_empty() {
        blocks.push(ContentBlock::text(text));
    }

    if blocks.is_empty() {
        blocks.push(ContentBlock::text(""));
    }

    blocks
}

/// Truncate tool result if it exceeds MAX_TOOL_RESULT_CHARS.
/// Preserves the end of the output as it often contains the conclusion/useful result.
fn truncate_tool_result(output: &str) -> String {
    if output.len() <= MAX_TOOL_RESULT_CHARS {
        return output.to_string();
    }

    let truncated_start_len = output.len().saturating_sub(TRUNCATION_SUFFIX_LEN);
    if truncated_start_len > MAX_TOOL_RESULT_CHARS {
        // Even after removing suffix, still too long - take from beginning
        format!(
            "{}...\n\n[Output truncated - {} characters removed]",
            &output[..output.ceil_char_boundary(MAX_TOOL_RESULT_CHARS - 100)],
            output.len() - MAX_TOOL_RESULT_CHARS + 100
        )
    } else {
        // Keep most of the end which usually contains the useful result
        format!(
            "...\n\n[Output truncated - {} characters removed]\n\n{}",
            truncated_start_len,
            &output[output.floor_char_boundary(truncated_start_len)..]
        )
    }
}

fn tool_result_preview(output: &str) -> String {
    if output.len() <= TOOL_PREVIEW_CHARS {
        output.to_string()
    } else {
        format!(
            "{}…",
            &output[..output.floor_char_boundary(TOOL_PREVIEW_CHARS)]
        )
    }
}

fn extend_unique_media(target: &mut Vec<MediaRef>, media_refs: &[MediaRef]) {
    for media_ref in media_refs {
        if !target.iter().any(|existing| {
            existing.path == media_ref.path && existing.media_type == media_ref.media_type
        }) {
            target.push(media_ref.clone());
        }
    }
}

fn attach_reply_media(message: &mut ChatMessage, reply_media_refs: &[MediaRef]) {
    extend_unique_media(&mut message.media_refs, reply_media_refs);
}

/// Drop only old complete Turns from the request-local history. Messages
/// appended by this AgentLoop invocation live at or after `history_len` and
/// are never removed, so already executed tool calls/results remain available
/// to the retry and cannot be executed a second time.
fn trim_old_complete_turns_for_overflow(
    messages: &mut Vec<ChatMessage>,
    history_len: usize,
    target_tokens: usize,
) -> usize {
    let history_len = history_len.min(messages.len());
    let protected_start = messages[..history_len]
        .iter()
        .rposition(|message| message.role == "user");
    let Some(protected_start) = protected_start else {
        return 0;
    };
    let system_end = messages[..history_len]
        .iter()
        .take_while(|message| message.role == "system")
        .count();
    let first_turn_start = messages[system_end..history_len]
        .iter()
        .position(|message| message.role == "user")
        .map(|index| system_end + index)
        .unwrap_or(system_end);

    let mut chosen_cut = None;
    for cut in (first_turn_start.saturating_add(1)..=protected_start)
        .filter(|index| messages[*index].role == "user")
    {
        chosen_cut = Some(cut);
        let projected = messages[..system_end]
            .iter()
            .chain(messages[cut..].iter())
            .cloned()
            .collect::<Vec<_>>();
        if estimate_tokens(&projected) <= target_tokens {
            break;
        }
    }

    let Some(cut) = chosen_cut else {
        return 0;
    };
    let dropped = cut.saturating_sub(system_end);
    messages.drain(system_end..cut);
    dropped
}

/// Loop detection result.
#[derive(Debug, Clone, PartialEq, Eq)]
enum LoopDetectionResult {
    /// No warning needed.
    Ok,
    /// Warning: same tool + args repeated N times.
    Warning(String),
}

/// Configuration for loop detector.
#[derive(Debug, Clone)]
struct LoopDetectorConfig {
    /// Master switch.
    enabled: bool,
    /// Warn every N consecutive identical calls.
    warn_every: usize,
}

impl Default for LoopDetectorConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            warn_every: 5,
        }
    }
}

/// A single recorded tool invocation in the sliding window.
#[derive(Debug, Clone)]
struct ToolCallRecord {
    name: String,
    args_hash: u64,
}

/// Stateful loop detector that monitors for repetitive patterns.
struct LoopDetector {
    config: LoopDetectorConfig,
    window: VecDeque<ToolCallRecord>,
}

impl LoopDetector {
    fn new(config: LoopDetectorConfig) -> Self {
        Self {
            window: VecDeque::with_capacity(config.warn_every * 2),
            config,
        }
    }

    /// Record a completed tool call and check for loop patterns.
    /// Returns Warning every `warn_every` consecutive identical calls.
    fn record(&mut self, name: &str, args: &serde_json::Value) -> LoopDetectionResult {
        if !self.config.enabled {
            return LoopDetectionResult::Ok;
        }

        let record = ToolCallRecord {
            name: name.to_string(),
            args_hash: hash_json_value(args),
        };

        // Maintain sliding window
        if self.window.len() >= self.config.warn_every * 2 {
            self.window.pop_front();
        }
        self.window.push_back(record);

        // Count consecutive identical calls
        let last = self.window.back().unwrap();
        let consecutive: usize = self
            .window
            .iter()
            .rev()
            .take_while(|r| r.name == last.name && r.args_hash == last.args_hash)
            .count();

        // Warn every warn_every times
        if consecutive > 0 && consecutive.is_multiple_of(self.config.warn_every) {
            LoopDetectionResult::Warning(format!(
                "注意: 工具 '{}' 已连续执行 {} 次，参数相同。如果任务没有进展，请尝试其他方法。",
                last.name, consecutive
            ))
        } else {
            LoopDetectionResult::Ok
        }
    }
}

/// Hash a JSON value deterministically (key-order independent).
fn hash_json_value(value: &serde_json::Value) -> u64 {
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    let canonical = canonicalise_json(value);
    canonical.hash(&mut hasher);
    hasher.finish()
}

/// Return a clone of value with all object keys sorted recursively.
fn canonicalise_json(value: &serde_json::Value) -> serde_json::Value {
    match value {
        serde_json::Value::Object(map) => {
            let mut sorted: Vec<(&String, &serde_json::Value)> = map.iter().collect();
            sorted.sort_by_key(|(k, _)| *k);
            let new_map: serde_json::Map<String, serde_json::Value> = sorted
                .into_iter()
                .map(|(k, v)| (k.clone(), canonicalise_json(v)))
                .collect();
            serde_json::Value::Object(new_map)
        }
        serde_json::Value::Array(arr) => {
            serde_json::Value::Array(arr.iter().map(canonicalise_json).collect())
        }
        other => other.clone(),
    }
}
/// AgentLoop - Stateless agent that processes messages with tool calling support.
/// History is managed externally by SessionManager.
pub struct AgentLoop {
    provider: Arc<dyn LLMProvider>,
    tools: Arc<ToolRegistry>,
    observer: Option<Arc<dyn Observer>>,
    max_iterations: usize,
    workspace_dir: PathBuf,
    model_name: String,
    context_trim_threshold: usize,
    input_types: Vec<String>,
    media_registry: MediaHandlerRegistry,
    /// Optional sink receiving a clone of every message appended to
    /// `emitted_messages` during `process_inner`.  Sub-agent runs use it to
    /// persist an incremental transcript; ordinary Turns leave it `None`.
    transcript_sink: Option<tokio::sync::mpsc::UnboundedSender<ChatMessage>>,
}
#[derive(Debug, Clone)]
pub struct AgentProcessResult {
    pub final_response: ChatMessage,
    pub emitted_messages: Vec<ChatMessage>,
    pub total_tokens: Option<u32>,
    pub usage: Option<crate::providers::Usage>,
    /// Provider usage for the final successful request in this Turn. This is
    /// the correct basis for context-window occupancy; `usage` is accumulated
    /// across every tool iteration.
    pub last_request_usage: Option<crate::providers::Usage>,
    /// Digest of the exact ChatMessage/tool-definition request associated
    /// with `last_request_usage`.
    pub last_request_digest: Option<u64>,
}

fn merge_usage(total: &mut crate::providers::Usage, next: &crate::providers::Usage) {
    total.prompt_tokens = total.prompt_tokens.saturating_add(next.prompt_tokens);
    total.completion_tokens = total
        .completion_tokens
        .saturating_add(next.completion_tokens);
    total.total_tokens = total.total_tokens.saturating_add(next.total_tokens);
    total.cached_tokens = sum_optional_tokens(total.cached_tokens, next.cached_tokens);
    total.cache_read_input_tokens =
        sum_optional_tokens(total.cache_read_input_tokens, next.cache_read_input_tokens);
    total.cache_creation_input_tokens = sum_optional_tokens(
        total.cache_creation_input_tokens,
        next.cache_creation_input_tokens,
    );
}

fn sum_optional_tokens(left: Option<u32>, right: Option<u32>) -> Option<u32> {
    match (left, right) {
        (None, None) => None,
        (left, right) => Some(
            left.unwrap_or_default()
                .saturating_add(right.unwrap_or_default()),
        ),
    }
}

impl AgentLoop {
    /// Create a new AgentLoop with a provider created from config.
    pub fn new(provider_config: LLMProviderConfig) -> Result<Self, AgentError> {
        let max_iterations = provider_config.max_tool_iterations;
        let model_name = provider_config.model_id.clone();
        let workspace_dir = provider_config.workspace_dir.clone();
        let input_types = provider_config.input_types.clone();
        let provider = create_provider(provider_config)
            .map_err(|e| AgentError::ProviderCreation(e.to_string()))?;

        Ok(Self {
            provider: Arc::from(provider),
            tools: Arc::new(ToolRegistry::new()),
            observer: None,
            context_trim_threshold: 0,
            max_iterations,
            workspace_dir,
            model_name,
            input_types,
            media_registry: MediaHandlerRegistry::with_defaults(),
            transcript_sink: None,
        })
    }

    /// Create a new AgentLoop with provider created from config and given tools.
    pub fn with_tools(
        provider_config: LLMProviderConfig,
        tools: Arc<ToolRegistry>,
    ) -> Result<Self, AgentError> {
        let max_iterations = provider_config.max_tool_iterations;
        let model_name = provider_config.model_id.clone();
        let workspace_dir = provider_config.workspace_dir.clone();
        let input_types = provider_config.input_types.clone();
        let provider = create_provider(provider_config)
            .map_err(|e| AgentError::ProviderCreation(e.to_string()))?;

        Ok(Self {
            provider: Arc::from(provider),
            tools,
            observer: None,
            context_trim_threshold: 0,
            max_iterations,
            workspace_dir,
            model_name,
            input_types,
            media_registry: MediaHandlerRegistry::with_defaults(),
            transcript_sink: None,
        })
    }

    /// Create a new AgentLoop with an existing shared provider.
    pub fn with_provider(
        provider: Arc<dyn LLMProvider>,
        max_iterations: usize,
        model_name: String,
        workspace_dir: PathBuf,
        input_types: Vec<String>,
    ) -> Self {
        Self {
            provider,
            tools: Arc::new(ToolRegistry::new()),
            observer: None,
            context_trim_threshold: 0,
            max_iterations,
            workspace_dir,
            model_name,
            input_types,
            media_registry: MediaHandlerRegistry::with_defaults(),
            transcript_sink: None,
        }
    }

    /// Create a new AgentLoop with an existing shared provider and given tools.
    pub fn with_provider_and_tools(
        provider: Arc<dyn LLMProvider>,
        tools: Arc<ToolRegistry>,
        max_iterations: usize,
        model_name: String,
        workspace_dir: PathBuf,
        input_types: Vec<String>,
    ) -> Self {
        Self {
            provider,
            tools,
            observer: None,
            context_trim_threshold: 0,
            max_iterations,
            workspace_dir,
            model_name,
            input_types,
            media_registry: MediaHandlerRegistry::with_defaults(),
            transcript_sink: None,
        }
    }

    /// Set the context window size for preemptive trimming.
    pub fn with_context_window(mut self, window: usize) -> Self {
        self.context_trim_threshold = window.saturating_sub(16_384.min(window / 2));
        self
    }

    /// Override the request-local tool trimming threshold with the same
    /// effective reserve threshold used by Session context compaction.
    pub fn with_context_trim_threshold(mut self, threshold: usize) -> Self {
        self.context_trim_threshold = threshold;
        self
    }

    /// Set the workspace directory.
    pub fn with_workspace_dir(mut self, dir: PathBuf) -> Self {
        self.workspace_dir = dir;
        self
    }

    /// Set an observer for tracking events.
    pub fn with_observer(mut self, observer: Arc<dyn Observer>) -> Self {
        self.observer = Some(observer);
        self
    }

    /// Attach a transcript sink that receives a clone of every message this
    /// loop appends to `emitted_messages`.  Used by sub-agent runs to persist
    /// an incremental transcript; ordinary Turns leave it unset.
    pub fn with_transcript_sink(
        mut self,
        sink: tokio::sync::mpsc::UnboundedSender<ChatMessage>,
    ) -> Self {
        self.transcript_sink = Some(sink);
        self
    }

    /// Forward a message to the transcript sink, if one is installed.  The
    /// sink is unbounded and the receiver outlives this loop, so send failures
    /// are impossible in practice; ignore them defensively.
    fn forward_to_transcript_sink(&self, message: &ChatMessage) {
        if let Some(sink) = &self.transcript_sink {
            let _ = sink.send(message.clone());
        }
    }

    /// Preemptive trim: truncate old tool results in-place when history is
    /// approaching the context window limit. Old results (outside of `keep_recent`
    /// zone) are replaced with a short placeholder; recent results are truncated
    /// to `max_chars`.
    fn preemptive_trim_old_tool_results(
        &self,
        messages: &mut [ChatMessage],
        max_chars: usize,
        keep_recent: usize,
    ) -> usize {
        let end = messages.len().saturating_sub(keep_recent);
        let start = 1; // protect system message at [0] if present
        let mut modified = 0;
        for message in messages.iter_mut().take(end).skip(start) {
            if message.role != "tool" {
                continue;
            }
            if message.content.len() <= max_chars {
                continue;
            }
            let tool_name = message.tool_name.as_deref().unwrap_or("unknown");
            let chars = message.content.len();
            message.content = format!(
                "[Tool output ({}) — {} chars, omitted from context]",
                tool_name, chars
            );
            modified += 1;
        }
        modified
    }

    pub fn tools(&self) -> &Arc<ToolRegistry> {
        &self.tools
    }

    /// Acquire the provider-step permit for this session when an execution
    /// gate is installed.  Root Turns are not Agent Runs but still share the
    /// gateway-wide step caps, so background runs cannot bypass them.
    async fn acquire_provider_permit(
        context: &ToolExecutionContext,
        cancellation: &tokio_util::sync::CancellationToken,
    ) -> Result<Option<crate::agent::gate::StepPermit>, AgentError> {
        match context.execution_gate.as_ref() {
            Some(gate) => gate
                .acquire_provider(context.session_id.as_deref(), cancellation)
                .await
                .map(Some)
                .map_err(|_| AgentError::Cancelled),
            None => Ok(None),
        }
    }

    async fn acquire_tool_permit(
        context: &ToolExecutionContext,
    ) -> Result<Option<crate::agent::gate::StepPermit>, AgentError> {
        match context.execution_gate.as_ref() {
            Some(gate) => gate
                .acquire_tool(context.session_id.as_deref(), &context.cancellation)
                .await
                .map(Some)
                .map_err(|_| AgentError::Cancelled),
            None => Ok(None),
        }
    }

    async fn stream_completion(
        &self,
        request: ChatCompletionRequest,
        iteration: u32,
        turn: Option<&AgentTurnContext>,
        cancellation: &tokio_util::sync::CancellationToken,
    ) -> Result<ChatCompletionResponse, AgentError> {
        let metrics = crate::observability::metrics::global_metrics();
        let provider_name = self.provider.name().to_string();
        let provider_model = self.provider.model_id().to_string();
        let start = Instant::now();

        let stream_result = tokio::select! {
            biased;
            _ = cancellation.cancelled() => {
                metrics.record_provider(
                    &provider_name,
                    &provider_model,
                    None,
                    start.elapsed().as_millis() as u64,
                    true,
                );
                return Err(AgentError::Cancelled);
            }
            result = self.provider.stream(request) => result,
        };
        let mut provider_stream = match stream_result {
            Ok(stream) => stream,
            Err(error) => {
                tracing::error!(error = %error, "LLM request failed");
                metrics.record_provider(
                    &provider_name,
                    &provider_model,
                    None,
                    start.elapsed().as_millis() as u64,
                    true,
                );
                let message = error.to_string();
                if is_context_overflow_error(&message) {
                    return Err(AgentError::ContextOverflow {
                        parsed_window: parse_context_limit_from_error(&message),
                        message,
                        tool_progress: false,
                    });
                }
                return Err(AgentError::LlmError(message));
            }
        };
        let mut accumulator = ProviderResponseAccumulator::default();
        loop {
            let next = tokio::select! {
                biased;
                _ = cancellation.cancelled() => {
                    metrics.record_provider(
                        &provider_name,
                        &provider_model,
                        None,
                        start.elapsed().as_millis() as u64,
                        true,
                    );
                    return Err(AgentError::Cancelled);
                }
                chunk = provider_stream.next() => chunk,
            };
            let Some(chunk) = next else { break };
            let chunk = match chunk {
                Ok(chunk) => chunk,
                Err(error) => {
                    tracing::error!(error = %error, "LLM stream failed");
                    metrics.record_provider(
                        &provider_name,
                        &provider_model,
                        None,
                        start.elapsed().as_millis() as u64,
                        true,
                    );
                    let message = error.to_string();
                    if is_context_overflow_error(&message) {
                        return Err(AgentError::ContextOverflow {
                            parsed_window: parse_context_limit_from_error(&message),
                            message,
                            tool_progress: false,
                        });
                    }
                    return Err(AgentError::LlmError(message));
                }
            };
            if let Some(turn) = turn {
                let event = match &chunk {
                    ProviderChunk::Reasoning(delta) => Some(TurnEvent::ReasoningDelta {
                        iteration,
                        delta: delta.clone(),
                    }),
                    ProviderChunk::Text(delta) => Some(TurnEvent::TextDelta {
                        iteration,
                        delta: delta.clone(),
                    }),
                    _ => None,
                };
                if let Some(event) = event {
                    turn.emitter.emit(event).map_err(|error| {
                        AgentError::Other(format!("turn event rejected: {error}"))
                    })?;
                }
            }
            accumulator.push(chunk);
        }
        let response = accumulator.finish();
        let latency_ms = start.elapsed().as_millis() as u64;
        metrics.record_provider(&provider_name, &provider_model, None, latency_ms, false);
        metrics.record_provider_tokens(&provider_name, &response.usage);
        Ok(response)
    }

    fn annotate_message(
        message: &mut ChatMessage,
        turn: Option<&AgentTurnContext>,
        iteration: u32,
        final_response: bool,
    ) {
        message.iteration = Some(iteration);
        if let Some(turn) = turn {
            message.turn_id = Some(turn.turn_id.clone());
            if final_response {
                message.id = turn.message_id.clone();
            }
        }
    }

    /// Add steering inputs to the in-memory transcript in receive order.
    /// User inputs remain ordinary `role=user` messages so every provider
    /// sees the same conversation semantics and persistence can commit them
    /// alongside the rest of this turn.  Agent steer inputs project to hidden
    /// user messages: the client renders the durable Signal projection, not
    /// a user bubble, while the model still sees the envelope.
    fn append_steering_messages(
        &self,
        messages: &mut Vec<ChatMessage>,
        emitted_messages: &mut Vec<ChatMessage>,
        consumed_steering: &mut Vec<TurnInput>,
        steering_inputs: Vec<TurnInput>,
        turn: &AgentTurnContext,
        iteration: u32,
    ) {
        for input in steering_inputs {
            let message = input
                .clone()
                .into_chat_message(turn.turn_id.clone(), iteration);
            consumed_steering.push(input);
            messages.push(message.clone());
            emitted_messages.push(message.clone());
            self.forward_to_transcript_sink(&message);
        }
    }

    fn close_steering(turn: Option<&AgentTurnContext>) {
        if let Some(mailbox) = turn.and_then(AgentTurnContext::steering) {
            mailbox.close();
        }
    }

    fn restore_steering(turn: Option<&AgentTurnContext>, consumed_steering: Vec<TurnInput>) {
        if consumed_steering.is_empty() {
            return;
        }
        if let Some(mailbox) = turn.and_then(AgentTurnContext::steering) {
            mailbox.restore_front(consumed_steering);
        }
    }

    fn chat_message_to_llm_message(&self, m: &ChatMessage, include_media: bool) -> Message {
        let content = if m.media_refs.is_empty() || !include_media {
            vec![ContentBlock::text(&m.content)]
        } else {
            let origin = match m.role.as_str() {
                "user" => MediaOrigin::User,
                "tool" => MediaOrigin::Tool(m.tool_name.as_deref().unwrap_or("unknown")),
                _ => MediaOrigin::Message,
            };
            // Provider APIs generally allow native image/audio blocks only in
            // user input (and, through provider-specific adaptation, current
            // tool results). Persisted assistant attachments are delivery
            // artifacts: replay their manifest as text, never as native media.
            let native_input_types = if matches!(m.role.as_str(), "user" | "tool") {
                self.input_types.as_slice()
            } else {
                &[]
            };
            build_content_blocks(
                &m.content,
                &m.media_refs,
                native_input_types,
                &self.media_registry,
                origin,
            )
        };

        Message {
            role: m.role.clone(),
            content,
            reasoning_content: m.reasoning_content.clone(),
            provider_state: m.provider_state.clone(),
            tool_call_id: m.tool_call_id.clone(),
            name: m.tool_name.clone(),
            tool_calls: m.tool_calls.clone(),
        }
    }

    fn messages_for_llm(&self, messages: &[ChatMessage]) -> Vec<Message> {
        messages
            .iter()
            .enumerate()
            .map(|(index, message)| {
                let include_media = should_include_message_media(messages, index);
                self.chat_message_to_llm_message(message, include_media)
            })
            .collect()
    }

    /// Process a message using the provided conversation history.
    /// History management is handled externally by SessionManager.
    ///
    /// This method supports multi-round tool calling: after executing tools,
    /// it loops back to the LLM with the tool results until either:
    /// - The LLM returns no more tool calls (final response)
    /// - Maximum iterations are reached
    pub async fn process(
        &self,
        messages: Vec<ChatMessage>,
    ) -> Result<AgentProcessResult, AgentError> {
        self.process_inner(messages, None, ToolExecutionContext::default())
            .await
    }

    pub async fn process_with_context(
        &self,
        messages: Vec<ChatMessage>,
        tool_context: ToolExecutionContext,
    ) -> Result<AgentProcessResult, AgentError> {
        self.process_inner(messages, None, tool_context).await
    }

    pub async fn process_streaming(
        &self,
        messages: Vec<ChatMessage>,
        turn: AgentTurnContext,
    ) -> Result<AgentProcessResult, AgentError> {
        let tool_context = ToolExecutionContext::default().with_turn_id(turn.turn_id.clone());
        self.process_inner(messages, Some(turn), tool_context).await
    }

    pub async fn process_streaming_with_context(
        &self,
        messages: Vec<ChatMessage>,
        turn: AgentTurnContext,
        mut tool_context: ToolExecutionContext,
    ) -> Result<AgentProcessResult, AgentError> {
        if tool_context.turn_id.is_none() {
            tool_context.turn_id = Some(turn.turn_id.clone());
        }
        self.process_inner(messages, Some(turn), tool_context).await
    }

    async fn process_inner(
        &self,
        mut messages: Vec<ChatMessage>,
        turn: Option<AgentTurnContext>,
        tool_context: ToolExecutionContext,
    ) -> Result<AgentProcessResult, AgentError> {
        let turn_start = Instant::now();
        let cancellation = tool_context.cancellation.clone();
        if cancellation.is_cancelled() {
            return Err(AgentError::Cancelled);
        }

        #[cfg(debug_assertions)]
        tracing::debug!(
            history_len = messages.len(),
            max_iterations = self.max_iterations,
            "Starting agent process"
        );

        // Build and inject system prompt if not present
        let has_system = messages.first().is_some_and(|m| m.role == "system");
        if !has_system {
            let system_prompt =
                build_system_prompt(&self.workspace_dir, &self.model_name, &self.tools);
            #[cfg(debug_assertions)]
            tracing::debug!("System prompt injected:\n{}", system_prompt);
            messages.insert(0, ChatMessage::system(system_prompt));
        }

        // Track tool calls for loop detection
        let mut loop_detector = LoopDetector::new(LoopDetectorConfig::default());
        let mut emitted_messages = Vec::new();
        // Steering messages are removed from the mailbox only at safe
        // boundaries.  Keep a local copy until this invocation commits; if a
        // later provider/tool request fails, restore them before Session
        // retries from persisted history.
        let mut consumed_steering = Vec::new();
        let mut reply_media_refs = Vec::new();
        let mut accumulated_tokens: u32 = 0;
        let mut accumulated_usage = crate::providers::Usage::default();
        let mut last_request_usage = None;
        let mut last_request_digest = None;
        let initial_history_len = messages.len();
        let mut completed_tool_batches = 0usize;
        let mut local_overflow_retry_used = false;

        for iteration in 0..self.max_iterations {
            if cancellation.is_cancelled() {
                Self::restore_steering(turn.as_ref(), consumed_steering);
                return Err(AgentError::Cancelled);
            }
            #[cfg(debug_assertions)]
            tracing::debug!(iteration, "Agent iteration started");
            let last_iteration = iteration.saturating_add(1) >= self.max_iterations;

            // Request-local safety check: use the same reserve threshold as
            // Session compaction, while leaving durable tool results intact.
            if self.context_trim_threshold > 0 {
                let estimated = estimate_tokens(&messages);
                if estimated > self.context_trim_threshold {
                    let trimmed = self.preemptive_trim_old_tool_results(&mut messages, 2000, 4);
                    if trimmed > 0 {
                        #[cfg(debug_assertions)]
                        tracing::debug!(
                            estimated,
                            threshold = self.context_trim_threshold,
                            trimmed_msgs = trimmed,
                            "Preemptive tool-result trim applied in loop"
                        );
                    }
                }
            }

            // Call LLM
            let iteration = match u32::try_from(iteration) {
                Ok(iteration) => iteration,
                Err(_) => {
                    Self::restore_steering(turn.as_ref(), consumed_steering);
                    return Err(AgentError::Other("tool iteration exceeds u32".to_string()));
                }
            };
            let (response, successful_request_digest) = loop {
                let tools = self.tools.has_tools().then(|| self.tools.get_definitions());
                let tool_signature = serde_json::to_string(&tools).unwrap_or_default();
                let request_digest = context_request_digest(&messages, &tool_signature);
                let request = ChatCompletionRequest {
                    messages: self.messages_for_llm(&messages),
                    temperature: None,
                    max_tokens: None,
                    tools,
                };
                let _provider_permit =
                    Self::acquire_provider_permit(&tool_context, &cancellation).await?;
                match self
                    .stream_completion(request, iteration, turn.as_ref(), &cancellation)
                    .await
                {
                    Ok(response) => break (response, request_digest),
                    Err(AgentError::ContextOverflow {
                        message,
                        parsed_window,
                        ..
                    }) if completed_tool_batches > 0 && !local_overflow_retry_used => {
                        let parsed_threshold = parsed_window
                            .map(|window| window.saturating_sub(16_384.min(window / 2)));
                        let target_tokens = match (self.context_trim_threshold, parsed_threshold) {
                            (0, Some(parsed)) => parsed,
                            (configured, Some(parsed)) => configured.min(parsed),
                            (configured, None) => configured,
                        };
                        let dropped = trim_old_complete_turns_for_overflow(
                            &mut messages,
                            initial_history_len,
                            target_tokens,
                        );
                        if dropped == 0 {
                            Self::restore_steering(turn.as_ref(), consumed_steering);
                            return Err(AgentError::ContextOverflow {
                                message,
                                parsed_window,
                                tool_progress: true,
                            });
                        }
                        local_overflow_retry_used = true;
                        tracing::warn!(
                            dropped_messages = dropped,
                            target_tokens,
                            "Retrying context overflow in the same AgentLoop after tool progress"
                        );
                    }
                    Err(AgentError::ContextOverflow {
                        message,
                        parsed_window,
                        ..
                    }) => {
                        Self::restore_steering(turn.as_ref(), consumed_steering);
                        return Err(AgentError::ContextOverflow {
                            message,
                            parsed_window,
                            tool_progress: completed_tool_batches > 0,
                        });
                    }
                    Err(error) => {
                        // The invocation may be retried from persisted history.
                        // Restore every steering message consumed by an earlier
                        // boundary; Session decides whether to retry or close
                        // and queue them after receiving this error.
                        Self::restore_steering(turn.as_ref(), consumed_steering);
                        return Err(error);
                    }
                }
            };

            accumulated_tokens = accumulated_tokens.saturating_add(response.usage.total_tokens);
            merge_usage(&mut accumulated_usage, &response.usage);
            last_request_usage = Some(response.usage.clone());
            last_request_digest = Some(successful_request_digest);

            #[cfg(debug_assertions)]
            tracing::debug!(
                iteration,
                response_len = response.content.len(),
                tool_calls_len = response.tool_calls.len(),
                "LLM response received"
            );

            // If no tool calls, this is normally the final response.  When a
            // steering message arrived and another model iteration remains,
            // preserve this assistant message as an intermediate transcript
            // entry and continue with the user input.  At the last iteration
            // there is no budget for another normal request, so close the
            // mailbox and let Session move any pending messages to its queue.
            if response.tool_calls.is_empty() {
                let mut assistant_message = ChatMessage::assistant(response.content);
                assistant_message.reasoning_content = response.reasoning_content;
                assistant_message.provider_state = response.provider_state;
                let steering = turn.as_ref().and_then(AgentTurnContext::steering);
                let pending = if last_iteration {
                    if let Some(mailbox) = steering.as_ref() {
                        mailbox.close();
                    }
                    None
                } else {
                    steering.as_ref().map(|mailbox| mailbox.drain_or_close())
                };

                if let Some(SteeringDrain::Messages(steering_messages)) = pending {
                    let Some(turn_context) = turn.as_ref() else {
                        unreachable!("steering messages require a turn context");
                    };
                    Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, false);
                    messages.push(assistant_message.clone());
                    emitted_messages.push(assistant_message.clone());
                    self.forward_to_transcript_sink(&assistant_message);
                    self.append_steering_messages(
                        &mut messages,
                        &mut emitted_messages,
                        &mut consumed_steering,
                        steering_messages,
                        turn_context,
                        iteration,
                    );
                    continue;
                }

                attach_reply_media(&mut assistant_message, &reply_media_refs);
                Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, true);
                emitted_messages.push(assistant_message.clone());
                self.forward_to_transcript_sink(&assistant_message);
                crate::observability::metrics::global_metrics().record_turn(
                    Some(&accumulated_usage),
                    turn_start.elapsed().as_millis() as u64,
                );
                return Ok(AgentProcessResult {
                    final_response: assistant_message,
                    emitted_messages,
                    total_tokens: Some(accumulated_tokens),
                    usage: Some(accumulated_usage),
                    last_request_usage,
                    last_request_digest,
                });
            }

            if let Some(turn) = turn.as_ref()
                && let Err(error) = turn
                    .emitter
                    .emit(TurnEvent::TextSegmentFinished { iteration })
            {
                Self::restore_steering(Some(turn), consumed_steering);
                return Err(AgentError::Other(format!("turn event rejected: {error}")));
            }

            // Execute tool calls. User-visible progress is emitted through the
            // structured TurnEvent stream, not a second notification channel.
            {
                let tools_info: Vec<String> = response
                    .tool_calls
                    .iter()
                    .map(|tc| {
                        let args = serde_json::to_string(&tc.arguments).unwrap_or_default();
                        let s = format!("{}:{}", tc.name, args);
                        s
                    })
                    .collect();
                tracing::info!(iteration, count = response.tool_calls.len(), tools = %tools_info.join(", "), "Tool calls detected, executing tools");
            }

            // Add assistant message with tool calls
            let mut assistant_message = ChatMessage::assistant_with_tool_calls(
                response.content.clone(),
                response.tool_calls.clone(),
            );
            assistant_message.reasoning_content = response.reasoning_content;
            assistant_message.provider_state = response.provider_state;
            Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, false);
            messages.push(assistant_message.clone());
            emitted_messages.push(assistant_message.clone());
            self.forward_to_transcript_sink(&assistant_message);

            // Execute tools and add results to messages
            let tool_results = match self
                .execute_tools(
                    &response.tool_calls,
                    iteration,
                    turn.as_ref(),
                    &tool_context,
                )
                .await
            {
                Ok(results) => results,
                Err(error) => {
                    Self::restore_steering(turn.as_ref(), consumed_steering);
                    return Err(error);
                }
            };

            for result in &tool_results {
                extend_unique_media(&mut reply_media_refs, &result.reply_media_refs);
            }

            for (tool_call, result) in response.tool_calls.iter().zip(tool_results.iter()) {
                // Log function call with name and arguments
                let args_str = match &tool_call.arguments {
                    serde_json::Value::Object(obj) if obj.is_empty() => "{}".to_string(),
                    other => {
                        serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string())
                    }
                };
                tracing::info!(tool = %tool_call.name, args = %args_str, "Calling tool");

                // Truncate tool result if too large
                let truncated_output = truncate_tool_result(&result.output);

                // Record tool call and check for loops
                let loop_result = loop_detector.record(&tool_call.name, &tool_call.arguments);

                match loop_result {
                    LoopDetectionResult::Warning(msg) => {
                        // Add warning and proceed
                        tracing::warn!(
                            tool = %tool_call.name,
                            "Loop warning: {}",
                            msg
                        );
                        let mut tool_message = ChatMessage::tool_with_media(
                            tool_call.id.clone(),
                            tool_call.name.clone(),
                            format!("{}\n\n[上一条结果]\n{}", msg, truncated_output),
                            result.model_media_refs.clone(),
                        );
                        Self::annotate_message(&mut tool_message, turn.as_ref(), iteration, false);
                        messages.push(tool_message.clone());
                        emitted_messages.push(tool_message.clone());
                        self.forward_to_transcript_sink(&tool_message);
                    }
                    LoopDetectionResult::Ok => {
                        let mut tool_message = ChatMessage::tool_with_media(
                            tool_call.id.clone(),
                            tool_call.name.clone(),
                            truncated_output,
                            result.model_media_refs.clone(),
                        );
                        Self::annotate_message(&mut tool_message, turn.as_ref(), iteration, false);
                        messages.push(tool_message.clone());
                        emitted_messages.push(tool_message.clone());
                        self.forward_to_transcript_sink(&tool_message);
                    }
                }
            }
            completed_tool_batches = completed_tool_batches.saturating_add(1);

            if let Some(outcome) = tool_context
                .scheduled_completion
                .as_ref()
                .and_then(|sink| sink.outcome())
            {
                Self::close_steering(turn.as_ref());
                let mut final_message = ChatMessage::assistant(outcome.message);
                attach_reply_media(&mut final_message, &reply_media_refs);
                Self::annotate_message(&mut final_message, turn.as_ref(), iteration, true);
                emitted_messages.push(final_message.clone());
                self.forward_to_transcript_sink(&final_message);
                crate::observability::metrics::global_metrics().record_turn(
                    Some(&accumulated_usage),
                    turn_start.elapsed().as_millis() as u64,
                );
                return Ok(AgentProcessResult {
                    final_response: final_message,
                    emitted_messages,
                    total_tokens: Some(accumulated_tokens),
                    usage: Some(accumulated_usage),
                    last_request_usage,
                    last_request_digest,
                });
            }

            // A complete tool batch is the first safe steering boundary.  Do
            // not drain at the final available iteration: those inputs must
            // remain in the closed mailbox for Session to queue after this
            // turn rather than being silently consumed by the summary call.
            if let Some(mailbox) = turn.as_ref().and_then(AgentTurnContext::steering) {
                if last_iteration {
                    mailbox.close();
                } else {
                    let steering_messages = mailbox.drain();
                    if !steering_messages.is_empty() {
                        let Some(turn_context) = turn.as_ref() else {
                            unreachable!("steering messages require a turn context");
                        };
                        self.append_steering_messages(
                            &mut messages,
                            &mut emitted_messages,
                            &mut consumed_steering,
                            steering_messages,
                            turn_context,
                            iteration,
                        );
                    }
                }
            }

            // Loop continues to next iteration with updated messages
            #[cfg(debug_assertions)]
            tracing::debug!(
                iteration,
                message_count = messages.len(),
                "Tool execution complete, continuing to next iteration"
            );
        }

        // Max iterations reached - ask LLM for a summary based on completed work
        // Any mailbox input still pending at this boundary cannot be consumed
        // without exceeding the configured tool-iteration budget.  Keep it in
        // the closed mailbox for Session's next-turn fallback.
        Self::close_steering(turn.as_ref());
        tracing::warn!("Max iterations reached, requesting final summary from LLM");

        // Add a message asking for summary
        let summary_request = ChatMessage::user(
            "You have reached the maximum number of tool call iterations. \
            Please provide your best answer based on the work completed so far.",
        );
        messages.push(summary_request);

        let summary_iteration = match u32::try_from(self.max_iterations) {
            Ok(iteration) => iteration,
            Err(_) => {
                Self::restore_steering(turn.as_ref(), consumed_steering);
                return Err(AgentError::Other("tool iteration exceeds u32".to_string()));
            }
        };
        let summary_result = loop {
            let request_digest = context_request_digest(&messages, "null");
            let request = ChatCompletionRequest {
                messages: self.messages_for_llm(&messages),
                temperature: None,
                max_tokens: None,
                tools: None,
            };
            let _provider_permit =
                Self::acquire_provider_permit(&tool_context, &cancellation).await?;
            match self
                .stream_completion(request, summary_iteration, turn.as_ref(), &cancellation)
                .await
            {
                Ok(response) => break Ok((response, request_digest)),
                Err(AgentError::ContextOverflow {
                    message,
                    parsed_window,
                    ..
                }) if completed_tool_batches > 0 && !local_overflow_retry_used => {
                    let parsed_threshold =
                        parsed_window.map(|window| window.saturating_sub(16_384.min(window / 2)));
                    let target_tokens = match (self.context_trim_threshold, parsed_threshold) {
                        (0, Some(parsed)) => parsed,
                        (configured, Some(parsed)) => configured.min(parsed),
                        (configured, None) => configured,
                    };
                    let dropped = trim_old_complete_turns_for_overflow(
                        &mut messages,
                        initial_history_len,
                        target_tokens,
                    );
                    if dropped == 0 {
                        break Err(AgentError::ContextOverflow {
                            message,
                            parsed_window,
                            tool_progress: true,
                        });
                    }
                    local_overflow_retry_used = true;
                    tracing::warn!(
                        dropped_messages = dropped,
                        target_tokens,
                        "Retrying final-summary context overflow in the same AgentLoop"
                    );
                }
                Err(AgentError::ContextOverflow {
                    message,
                    parsed_window,
                    ..
                }) => {
                    break Err(AgentError::ContextOverflow {
                        message,
                        parsed_window,
                        tool_progress: completed_tool_batches > 0,
                    });
                }
                Err(error) => break Err(error),
            }
        };
        match summary_result {
            Ok((response, summary_request_digest)) => {
                accumulated_tokens = accumulated_tokens.saturating_add(response.usage.total_tokens);
                merge_usage(&mut accumulated_usage, &response.usage);
                last_request_usage = Some(response.usage.clone());
                last_request_digest = Some(summary_request_digest);
                let mut assistant_message = ChatMessage::assistant(response.content);
                assistant_message.reasoning_content = response.reasoning_content;
                assistant_message.provider_state = response.provider_state;
                attach_reply_media(&mut assistant_message, &reply_media_refs);
                Self::annotate_message(
                    &mut assistant_message,
                    turn.as_ref(),
                    summary_iteration,
                    true,
                );
                emitted_messages.push(assistant_message.clone());
                self.forward_to_transcript_sink(&assistant_message);
                crate::observability::metrics::global_metrics().record_turn(
                    Some(&accumulated_usage),
                    turn_start.elapsed().as_millis() as u64,
                );
                Ok(AgentProcessResult {
                    final_response: assistant_message,
                    emitted_messages,
                    total_tokens: Some(accumulated_tokens),
                    usage: Some(accumulated_usage),
                    last_request_usage,
                    last_request_digest,
                })
            }
            Err(error @ AgentError::ContextOverflow { .. }) => {
                Self::restore_steering(turn.as_ref(), consumed_steering);
                Err(error)
            }
            Err(e) => {
                // Fallback if summary call fails
                tracing::error!(error = %e, "Failed to get summary from LLM");
                let fallback = format!(
                    "I reached the maximum number of tool call iterations ({}) without completing the task. The work done so far has been lost due to an error. Please try breaking the task into smaller steps.",
                    self.max_iterations
                );
                if let Some(turn) = turn.as_ref()
                    && let Err(error) = turn
                        .emitter
                        .emit(TurnEvent::TextSegmentFinished {
                            iteration: summary_iteration,
                        })
                        .and_then(|()| {
                            turn.emitter.emit(TurnEvent::TextDelta {
                                iteration: summary_iteration,
                                delta: fallback.clone(),
                            })
                        })
                {
                    Self::restore_steering(Some(turn), consumed_steering);
                    return Err(AgentError::Other(format!("turn event rejected: {error}")));
                }
                let mut final_message = ChatMessage::assistant(fallback);
                attach_reply_media(&mut final_message, &reply_media_refs);
                Self::annotate_message(&mut final_message, turn.as_ref(), summary_iteration, true);
                emitted_messages.push(final_message.clone());
                self.forward_to_transcript_sink(&final_message);
                let turn_usage = (accumulated_usage.total_tokens > 0).then_some(&accumulated_usage);
                crate::observability::metrics::global_metrics()
                    .record_turn(turn_usage, turn_start.elapsed().as_millis() as u64);
                Ok(AgentProcessResult {
                    final_response: final_message,
                    emitted_messages,
                    total_tokens: if accumulated_tokens > 0 {
                        Some(accumulated_tokens)
                    } else {
                        None
                    },
                    usage: (accumulated_usage.total_tokens > 0).then_some(accumulated_usage),
                    last_request_usage,
                    last_request_digest,
                })
            }
        }
    }

    /// Determine whether to execute tools in parallel or sequentially.
    ///
    /// Returns true if:
    /// - There are multiple tool calls
    /// - None of the tools require sequential execution (tool_search, non-concurrency-safe)
    fn should_execute_in_parallel(&self, tool_calls: &[ToolCall]) -> bool {
        if tool_calls.len() <= 1 {
            return false;
        }

        // tool_search must run sequentially to avoid MCP activation race conditions
        if tool_calls.iter().any(|tc| tc.name == "tool_search") {
            return false;
        }

        // All tools must be concurrency-safe to run in parallel
        tool_calls.iter().all(|tc| {
            self.tools
                .get(&tc.name)
                .map(|t| t.concurrency_safe())
                .unwrap_or(false)
        })
    }

    /// Execute multiple tool calls, choosing parallel or sequential based on conditions.
    async fn execute_tools(
        &self,
        tool_calls: &[ToolCall],
        iteration: u32,
        turn: Option<&AgentTurnContext>,
        context: &ToolExecutionContext,
    ) -> Result<Vec<ToolExecutionOutcome>, AgentError> {
        if self.should_execute_in_parallel(tool_calls) {
            tracing::debug!("Executing {} tools in parallel", tool_calls.len());
            self.execute_tools_parallel(tool_calls, iteration, turn, context)
                .await
        } else {
            tracing::debug!("Executing {} tools sequentially", tool_calls.len());
            self.execute_tools_sequential(tool_calls, iteration, turn, context)
                .await
        }
    }

    /// Execute tools in parallel using join_all.  Cancellation wins over a
    /// completed batch: once the token fires, PicoBot no longer owns the
    /// results.  Dropping the tool futures only means PicoBot stops waiting;
    /// external side effects are not rolled back and late results must be
    /// blocked by execution-ID conditional commits downstream.
    async fn execute_tools_parallel(
        &self,
        tool_calls: &[ToolCall],
        iteration: u32,
        turn: Option<&AgentTurnContext>,
        context: &ToolExecutionContext,
    ) -> Result<Vec<ToolExecutionOutcome>, AgentError> {
        let futures: Vec<_> = tool_calls
            .iter()
            .map(|tool_call| self.execute_one_tool(tool_call, iteration, turn, context))
            .collect();

        tokio::select! {
            biased;
            _ = context.cancellation.cancelled() => Err(AgentError::Cancelled),
            results = futures_util::future::join_all(futures) => {
                results.into_iter().collect()
            }
        }
    }

    /// Execute tools sequentially.
    async fn execute_tools_sequential(
        &self,
        tool_calls: &[ToolCall],
        iteration: u32,
        turn: Option<&AgentTurnContext>,
        context: &ToolExecutionContext,
    ) -> Result<Vec<ToolExecutionOutcome>, AgentError> {
        let mut outcomes = Vec::with_capacity(tool_calls.len());

        for tool_call in tool_calls {
            if context
                .scheduled_completion
                .as_ref()
                .is_some_and(|sink| sink.is_completed())
            {
                outcomes.push(ToolExecutionOutcome::failure(
                    "Cancelled: scheduled run was already completed".to_string(),
                    Some("scheduled run was already completed".to_string()),
                ));
                continue;
            }
            if context.cancellation.is_cancelled() {
                return Err(AgentError::Cancelled);
            }
            let outcome = tokio::select! {
                biased;
                _ = context.cancellation.cancelled() => return Err(AgentError::Cancelled),
                result = self.execute_one_tool(tool_call, iteration, turn, context) => result?,
            };
            outcomes.push(outcome);
        }

        Ok(outcomes)
    }

    /// Execute a single tool and return the outcome with event tracking.
    async fn execute_one_tool(
        &self,
        tool_call: &ToolCall,
        iteration: u32,
        turn: Option<&AgentTurnContext>,
        context: &ToolExecutionContext,
    ) -> Result<ToolExecutionOutcome, AgentError> {
        let start = Instant::now();
        let tool_name = tool_call.name.clone();

        if let Some(turn) = turn {
            turn.emitter
                .emit(TurnEvent::ToolStarted {
                    iteration,
                    call: tool_call.clone(),
                })
                .map_err(|error| AgentError::Other(format!("turn event rejected: {error}")))?;
        }

        // Record ToolCallStart event
        if let Some(ref observer) = self.observer {
            observer.record_event(&ObserverEvent::ToolCallStart {
                tool: tool_name.clone(),
                arguments: Some(truncate_args(&tool_call.arguments, 300)),
            });
        }

        let tool_permit = Self::acquire_tool_permit(context).await?;
        let result = self.execute_tool_internal(tool_call, context).await;
        drop(tool_permit);
        let duration = start.elapsed();

        if let Some(turn) = turn {
            turn.emitter
                .emit(TurnEvent::ToolFinished {
                    iteration,
                    call_id: tool_call.id.clone(),
                    success: result.success,
                    preview: Some(tool_result_preview(&truncate_tool_result(&result.output))),
                })
                .map_err(|error| AgentError::Other(format!("turn event rejected: {error}")))?;
        }

        // Record ToolCall event
        if let Some(ref observer) = self.observer {
            observer.record_event(&ObserverEvent::ToolCall {
                tool: tool_name.clone(),
                duration,
                success: result.success,
            });
        }

        crate::observability::metrics::global_metrics()
            .record_tool_call(&tool_name, result.success);

        // Apply duration
        Ok(ToolExecutionOutcome { duration, ..result })
    }

    /// Internal tool execution without event tracking.
    async fn execute_tool_internal(
        &self,
        tool_call: &ToolCall,
        context: &ToolExecutionContext,
    ) -> ToolExecutionOutcome {
        let tool = match self.tools.get(&tool_call.name) {
            Some(t) => t,
            None => {
                tracing::warn!(tool = %tool_call.name, "Tool not found");
                return ToolExecutionOutcome::failure(
                    format!("Error: Tool '{}' not found", tool_call.name),
                    Some(format!("Tool '{}' not found", tool_call.name)),
                );
            }
        };

        match tool
            .execute_with_context(context, tool_call.arguments.clone())
            .await
        {
            Ok(output) => {
                let processed = ToolOutputProcessor::process(output);
                let result = processed.result;
                if result.success {
                    ToolExecutionOutcome::success_with_output(
                        result.output,
                        processed.model_media_refs,
                        processed.reply_media_refs,
                    )
                } else {
                    let error = result.error.unwrap_or_default();
                    ToolExecutionOutcome::failure(format!("Error: {}", error), Some(error))
                }
            }
            Err(e) => {
                tracing::error!(tool = %tool_call.name, error = %e, "Tool execution failed");
                ToolExecutionOutcome::failure(format!("Error: {}", e), Some(e.to_string()))
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::Duration;

    use crate::agent::TurnMailbox;
    use crate::observability::{MultiObserver, Observer};
    use crate::providers::{
        ChatCompletionResponse, FinishReason, ProviderChunk, ProviderStream, Usage,
    };
    use crate::session::{TurnBlock, TurnController};
    use crate::tools::{FileReadTool, Tool, ToolArtifact, ToolOutput, ToolResult};

    struct TestObserver {
        events: std::sync::Mutex<Vec<ObserverEvent>>,
    }

    struct StreamingTextProvider;

    struct ErrorAfterFirstProvider {
        requests: std::sync::Mutex<usize>,
    }

    struct OverflowAfterToolProvider {
        requests: std::sync::Mutex<Vec<ChatCompletionRequest>>,
        fail_local_retry: bool,
    }

    struct AlwaysOverflowProvider {
        requests: std::sync::atomic::AtomicUsize,
    }

    struct ScheduledCompletionProvider {
        requests: std::sync::atomic::AtomicUsize,
    }

    #[async_trait::async_trait]
    impl LLMProvider for ScheduledCompletionProvider {
        async fn stream(
            &self,
            _request: ChatCompletionRequest,
        ) -> Result<ProviderStream, crate::providers::DynProviderError> {
            self.requests
                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
            Ok(crate::providers::provider_stream_for_test(
                ChatCompletionResponse {
                    id: "scheduled-complete".to_string(),
                    model: "scheduled-complete".to_string(),
                    content: String::new(),
                    reasoning_content: None,
                    provider_state: None,
                    tool_calls: vec![
                        ToolCall {
                            id: "complete".to_string(),
                            name: "complete_scheduled_run".to_string(),
                            arguments: serde_json::json!({
                                "outcome": "ok",
                                "message": "healthy"
                            }),
                        },
                        ToolCall {
                            id: "late-side-effect".to_string(),
                            name: "side_effect".to_string(),
                            arguments: serde_json::json!({}),
                        },
                    ],
                    usage: Usage::default(),
                },
            ))
        }

        fn ptype(&self) -> &str {
            "test"
        }

        fn name(&self) -> &str {
            "scheduled-complete"
        }

        fn model_id(&self) -> &str {
            "scheduled-complete"
        }
    }

    #[async_trait::async_trait]
    impl LLMProvider for AlwaysOverflowProvider {
        async fn stream(
            &self,
            _request: ChatCompletionRequest,
        ) -> Result<ProviderStream, crate::providers::DynProviderError> {
            self.requests
                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
            Err(Box::new(std::io::Error::other(
                "maximum context length is 4096 tokens",
            )))
        }

        fn ptype(&self) -> &str {
            "test"
        }

        fn name(&self) -> &str {
            "always-overflow"
        }

        fn model_id(&self) -> &str {
            "always-overflow"
        }
    }

    struct CountingSideEffectTool {
        executions: Arc<std::sync::atomic::AtomicUsize>,
    }

    #[async_trait::async_trait]
    impl Tool for CountingSideEffectTool {
        fn name(&self) -> &str {
            "side_effect"
        }

        fn description(&self) -> &str {
            "increments a test counter"
        }

        fn parameters_schema(&self) -> serde_json::Value {
            serde_json::json!({ "type": "object" })
        }

        fn read_only(&self) -> bool {
            false
        }

        async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
            self.executions
                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
            Ok(ToolResult {
                success: true,
                output: "side effect completed".to_string(),
                error: None,
            })
        }
    }

    #[tokio::test]
    async fn scheduled_completion_ends_the_loop_and_cancels_later_batch_calls() {
        let provider = Arc::new(ScheduledCompletionProvider {
            requests: std::sync::atomic::AtomicUsize::new(0),
        });
        let executions = Arc::new(std::sync::atomic::AtomicUsize::new(0));
        let tools = Arc::new(ToolRegistry::new());
        tools.register(crate::tools::CompleteScheduledRunTool::new());
        tools.register(CountingSideEffectTool {
            executions: executions.clone(),
        });
        let agent = AgentLoop::with_provider_and_tools(
            provider.clone(),
            tools,
            3,
            "scheduled-complete".to_string(),
            PathBuf::from("."),
            Vec::new(),
        );
        let sink = Arc::new(crate::tools::ScheduledCompletionSink::default());
        let context = ToolExecutionContext::for_session("scheduled-run:1")
            .with_execution_origin(crate::tools::ExecutionOrigin::Scheduled { job_run_id: 1 })
            .with_scheduled_completion(sink.clone());

        let result = agent
            .process_with_context(vec![ChatMessage::user("check")], context)
            .await
            .unwrap();

        assert_eq!(result.final_response.content, "healthy");
        assert_eq!(
            sink.outcome().unwrap().kind,
            crate::storage::ScheduledOutcomeKind::Ok
        );
        assert_eq!(executions.load(std::sync::atomic::Ordering::SeqCst), 0);
        assert_eq!(
            provider.requests.load(std::sync::atomic::Ordering::SeqCst),
            1
        );
        assert!(result.emitted_messages.iter().any(|message| {
            message.role == "tool"
                && message
                    .content
                    .contains("scheduled run was already completed")
        }));
    }

    #[async_trait::async_trait]
    impl LLMProvider for OverflowAfterToolProvider {
        async fn stream(
            &self,
            request: ChatCompletionRequest,
        ) -> Result<ProviderStream, crate::providers::DynProviderError> {
            let request_number = {
                let mut requests = self.requests.lock().unwrap();
                requests.push(request);
                requests.len()
            };
            if request_number == 2 || (request_number == 3 && self.fail_local_retry) {
                return Err(Box::new(std::io::Error::other(
                    "maximum context length is 4096 tokens",
                )));
            }
            let response = if request_number == 1 {
                ChatCompletionResponse {
                    id: "tool-call".to_string(),
                    model: "overflow-after-tool".to_string(),
                    content: String::new(),
                    reasoning_content: None,
                    provider_state: None,
                    tool_calls: vec![ToolCall {
                        id: "call-side-effect".to_string(),
                        name: "side_effect".to_string(),
                        arguments: serde_json::json!({}),
                    }],
                    usage: Usage::default(),
                }
            } else {
                ChatCompletionResponse {
                    id: "final".to_string(),
                    model: "overflow-after-tool".to_string(),
                    content: "completed without repeating the tool".to_string(),
                    reasoning_content: None,
                    provider_state: None,
                    tool_calls: Vec::new(),
                    usage: Usage::default(),
                }
            };
            Ok(crate::providers::provider_stream_for_test(response))
        }

        fn ptype(&self) -> &str {
            "test"
        }

        fn name(&self) -> &str {
            "overflow-after-tool"
        }

        fn model_id(&self) -> &str {
            "overflow-after-tool"
        }
    }

    #[async_trait::async_trait]
    impl LLMProvider for StreamingTextProvider {
        async fn stream(
            &self,
            _request: ChatCompletionRequest,
        ) -> Result<ProviderStream, crate::providers::DynProviderError> {
            let chunks = vec![
                ProviderChunk::Metadata {
                    id: "response".into(),
                    model: "streaming-test".into(),
                },
                ProviderChunk::Reasoning("because ".into()),
                ProviderChunk::Reasoning("facts".into()),
                ProviderChunk::Text("hello ".into()),
                ProviderChunk::Text("world".into()),
                ProviderChunk::ProviderState(crate::bus::ProviderReasoningState {
                    provider: "test".into(),
                    payload: serde_json::json!({"opaque":"state"}),
                }),
                ProviderChunk::Usage(Usage {
                    prompt_tokens: 2,
                    completion_tokens: 3,
                    total_tokens: 5,
                    ..Usage::default()
                }),
                ProviderChunk::Done(FinishReason::Stop),
            ];
            Ok(Box::pin(futures_util::stream::iter(
                chunks.into_iter().map(Ok),
            )))
        }

        fn ptype(&self) -> &str {
            "test"
        }

        fn name(&self) -> &str {
            "streaming-test"
        }

        fn model_id(&self) -> &str {
            "streaming-test"
        }
    }

    #[async_trait::async_trait]
    impl LLMProvider for ErrorAfterFirstProvider {
        async fn stream(
            &self,
            _request: ChatCompletionRequest,
        ) -> Result<ProviderStream, crate::providers::DynProviderError> {
            let request_number = {
                let mut requests = self.requests.lock().unwrap();
                *requests += 1;
                *requests
            };
            if request_number > 1 {
                return Err(Box::new(std::io::Error::other(
                    "synthetic provider failure",
                )));
            }
            let chunks = vec![
                ProviderChunk::Metadata {
                    id: "first".into(),
                    model: "error-after-first".into(),
                },
                ProviderChunk::Text("first response".into()),
                ProviderChunk::Usage(Usage {
                    prompt_tokens: 1,
                    completion_tokens: 1,
                    total_tokens: 2,
                    ..Usage::default()
                }),
                ProviderChunk::Done(FinishReason::Stop),
            ];
            Ok(Box::pin(futures_util::stream::iter(
                chunks.into_iter().map(Ok),
            )))
        }

        fn ptype(&self) -> &str {
            "test"
        }

        fn name(&self) -> &str {
            "error-after-first"
        }

        fn model_id(&self) -> &str {
            "error-after-first"
        }
    }

    #[tokio::test]
    async fn process_streaming_emits_turn_blocks_and_stamps_durable_message() {
        let agent = AgentLoop::with_provider(
            Arc::new(StreamingTextProvider),
            1,
            "streaming-test".into(),
            PathBuf::from("."),
            Vec::new(),
        );
        let (controller, emitter, _) = TurnController::start("session", "assistant-id");
        let initial = controller.snapshot();
        let context =
            AgentTurnContext::new(initial.id.0.clone(), initial.message_id.clone(), emitter);

        let result = agent
            .process_streaming(vec![ChatMessage::user("hi")], context)
            .await
            .unwrap();

        assert_eq!(result.final_response.id, "assistant-id");
        assert_eq!(result.final_response.content, "hello world");
        assert_eq!(
            result.final_response.reasoning_content.as_deref(),
            Some("because facts")
        );
        assert_eq!(result.final_response.turn_id, Some(initial.id.0.clone()));
        assert_eq!(result.final_response.iteration, Some(0));
        assert_eq!(
            result
                .final_response
                .provider_state
                .as_ref()
                .map(|state| state.provider.as_str()),
            Some("test")
        );
        assert_eq!(
            result.usage.as_ref().map(|usage| usage.total_tokens),
            Some(5)
        );

        let snapshot = controller.snapshot();
        assert_eq!(snapshot.blocks.len(), 2);
        assert!(matches!(
            &snapshot.blocks[0],
            TurnBlock::Reasoning { text, .. } if text == "because facts"
        ));
        assert!(matches!(
            &snapshot.blocks[1],
            TurnBlock::Assistant { text, .. } if text == "hello world"
        ));
    }

    #[tokio::test]
    async fn steering_after_final_response_is_injected_at_next_model_boundary() {
        let agent = AgentLoop::with_provider(
            Arc::new(StreamingTextProvider),
            2,
            "streaming-test".into(),
            PathBuf::from("."),
            Vec::new(),
        );
        let mailbox = Arc::new(TurnMailbox::new());
        mailbox
            .try_push_user(crate::agent::TurnInput::user(
                "steer-1",
                "please include the log summary",
                Vec::new(),
                None,
                1_000,
            ))
            .unwrap();
        let (controller, emitter, _) = TurnController::start("session", "assistant-id");
        let initial = controller.snapshot();
        let context = AgentTurnContext::new_with_steering(
            initial.id.0.clone(),
            initial.message_id.clone(),
            emitter,
            mailbox.clone(),
        );

        let result = agent
            .process_streaming(vec![ChatMessage::user("hi")], context)
            .await
            .unwrap();

        let positions: Vec<(usize, &str)> = result
            .emitted_messages
            .iter()
            .enumerate()
            .map(|(index, message)| (index, message.role.as_str()))
            .collect();
        assert_eq!(
            positions,
            vec![(0, "assistant"), (1, "user"), (2, "assistant")]
        );
        assert_eq!(
            result.emitted_messages[1].content,
            "please include the log summary"
        );
        assert_eq!(result.final_response.content, "hello world");
        assert!(mailbox.is_closed());
        assert!(mailbox.is_empty());
    }

    #[tokio::test]
    async fn provider_error_after_steering_drain_restores_input_for_retry() {
        let agent = AgentLoop::with_provider(
            Arc::new(ErrorAfterFirstProvider {
                requests: std::sync::Mutex::new(0),
            }),
            2,
            "error-after-first".into(),
            PathBuf::from("."),
            Vec::new(),
        );
        let mailbox = Arc::new(TurnMailbox::new());
        mailbox
            .try_push_user(crate::agent::TurnInput::user(
                "steer-1",
                "retry me",
                Vec::new(),
                None,
                1_000,
            ))
            .unwrap();
        let (controller, emitter, _) = TurnController::start("session", "assistant-id");
        let initial = controller.snapshot();
        let context = AgentTurnContext::new_with_steering(
            initial.id.0.clone(),
            initial.message_id.clone(),
            emitter,
            mailbox.clone(),
        );

        let error = agent
            .process_streaming(vec![ChatMessage::user("hi")], context)
            .await
            .unwrap_err();
        assert!(
            matches!(error, AgentError::LlmError(message) if message.contains("synthetic provider failure"))
        );
        assert!(!mailbox.is_closed());
        let restored = mailbox.take_pending();
        assert_eq!(restored.len(), 1);
        assert_eq!(restored.user_inputs[0].content, "retry me");
    }

    #[tokio::test]
    async fn overflow_after_tool_progress_retries_locally_without_reexecuting_tool() {
        let provider = Arc::new(OverflowAfterToolProvider {
            requests: std::sync::Mutex::new(Vec::new()),
            fail_local_retry: false,
        });
        let executions = Arc::new(std::sync::atomic::AtomicUsize::new(0));
        let tools = Arc::new(ToolRegistry::new());
        tools.register(CountingSideEffectTool {
            executions: executions.clone(),
        });
        let agent = AgentLoop::with_provider_and_tools(
            provider.clone(),
            tools,
            3,
            "overflow-after-tool".to_string(),
            PathBuf::from("."),
            Vec::new(),
        )
        .with_context_window(4_096);

        let result = agent
            .process(vec![
                ChatMessage::user("old turn"),
                ChatMessage::assistant("old answer"),
                ChatMessage::user("current turn"),
            ])
            .await
            .unwrap();

        assert_eq!(
            result.final_response.content,
            "completed without repeating the tool"
        );
        assert_eq!(executions.load(std::sync::atomic::Ordering::SeqCst), 1);
        let requests = provider.requests.lock().unwrap();
        assert_eq!(requests.len(), 3);
        assert!(requests[1].messages.iter().any(|message| {
            message
                .content
                .iter()
                .any(|block| matches!(block, ContentBlock::Text { text } if text == "old turn"))
        }));
        assert!(!requests[2].messages.iter().any(|message| {
            message
                .content
                .iter()
                .any(|block| matches!(block, ContentBlock::Text { text } if text == "old turn"))
        }));
        assert!(
            requests[2]
                .messages
                .iter()
                .any(|message| message.role == "tool")
        );
    }

    #[tokio::test]
    async fn second_overflow_after_tool_progress_does_not_issue_third_retry() {
        let provider = Arc::new(OverflowAfterToolProvider {
            requests: std::sync::Mutex::new(Vec::new()),
            fail_local_retry: true,
        });
        let executions = Arc::new(std::sync::atomic::AtomicUsize::new(0));
        let tools = Arc::new(ToolRegistry::new());
        tools.register(CountingSideEffectTool {
            executions: executions.clone(),
        });
        let agent = AgentLoop::with_provider_and_tools(
            provider.clone(),
            tools,
            3,
            "overflow-after-tool".to_string(),
            PathBuf::from("."),
            Vec::new(),
        )
        .with_context_window(4_096);

        let error = agent
            .process(vec![
                ChatMessage::user("old turn"),
                ChatMessage::assistant("old answer"),
                ChatMessage::user("current turn"),
            ])
            .await
            .unwrap_err();

        assert!(matches!(
            error,
            AgentError::ContextOverflow {
                tool_progress: true,
                ..
            }
        ));
        assert_eq!(executions.load(std::sync::atomic::Ordering::SeqCst), 1);
        assert_eq!(provider.requests.lock().unwrap().len(), 3);
    }

    #[tokio::test]
    async fn final_summary_overflow_after_tool_progress_uses_same_safe_retry() {
        let provider = Arc::new(OverflowAfterToolProvider {
            requests: std::sync::Mutex::new(Vec::new()),
            fail_local_retry: false,
        });
        let executions = Arc::new(std::sync::atomic::AtomicUsize::new(0));
        let tools = Arc::new(ToolRegistry::new());
        tools.register(CountingSideEffectTool {
            executions: executions.clone(),
        });
        let agent = AgentLoop::with_provider_and_tools(
            provider.clone(),
            tools,
            1,
            "overflow-after-tool".to_string(),
            PathBuf::from("."),
            Vec::new(),
        )
        .with_context_window(4_096);

        let result = agent
            .process(vec![
                ChatMessage::user("old turn"),
                ChatMessage::assistant("old answer"),
                ChatMessage::user("current turn"),
            ])
            .await
            .unwrap();

        assert_eq!(
            result.final_response.content,
            "completed without repeating the tool"
        );
        assert_eq!(executions.load(std::sync::atomic::Ordering::SeqCst), 1);
        let requests = provider.requests.lock().unwrap();
        assert_eq!(requests.len(), 3);
        assert!(
            requests[2]
                .messages
                .iter()
                .any(|message| message.role == "tool")
        );
    }

    #[tokio::test]
    async fn initial_overflow_is_typed_for_session_checkpoint_recovery() {
        let provider = Arc::new(AlwaysOverflowProvider {
            requests: std::sync::atomic::AtomicUsize::new(0),
        });
        let agent = AgentLoop::with_provider(
            provider.clone(),
            2,
            "always-overflow".to_string(),
            PathBuf::from("."),
            Vec::new(),
        );

        let error = agent
            .process(vec![ChatMessage::user("current turn")])
            .await
            .unwrap_err();

        assert!(matches!(
            error,
            AgentError::ContextOverflow {
                parsed_window: Some(4_096),
                tool_progress: false,
                ..
            }
        ));
        assert_eq!(
            provider.requests.load(std::sync::atomic::Ordering::SeqCst),
            1
        );
    }

    impl TestObserver {
        fn new() -> Self {
            Self {
                events: std::sync::Mutex::new(Vec::new()),
            }
        }
    }

    impl Observer for TestObserver {
        fn record_event(&self, event: &ObserverEvent) {
            self.events.lock().unwrap().push(event.clone());
        }

        fn name(&self) -> &str {
            "test_observer"
        }
    }

    #[tokio::test]
    async fn test_observer_receives_tool_events() {
        // Verify MultiObserver works
        let mut multi = MultiObserver::new();
        multi.add_observer(Box::new(TestObserver::new()));

        let event = ObserverEvent::ToolCallStart {
            tool: "test".to_string(),
            arguments: Some("{}".to_string()),
        };
        multi.record_event(&event);

        // Just verify the structure works
        assert_eq!(multi.len(), 1);
    }

    struct ToolMediaProvider {
        image_path: String,
        tool_name: String,
        requests: std::sync::Mutex<Vec<ChatCompletionRequest>>,
    }

    #[async_trait::async_trait]
    impl LLMProvider for ToolMediaProvider {
        async fn stream(
            &self,
            request: ChatCompletionRequest,
        ) -> Result<crate::providers::ProviderStream, crate::providers::DynProviderError> {
            let call_number = {
                let mut requests = self.requests.lock().unwrap();
                requests.push(request);
                requests.len()
            };
            let response = ChatCompletionResponse {
                id: format!("response-{call_number}"),
                model: "vision-test".to_string(),
                content: if call_number == 1 {
                    String::new()
                } else {
                    "image seen".to_string()
                },
                reasoning_content: None,
                provider_state: None,
                tool_calls: if call_number == 1 {
                    vec![ToolCall {
                        id: "call-image".to_string(),
                        name: self.tool_name.clone(),
                        arguments: serde_json::json!({ "path": self.image_path }),
                    }]
                } else {
                    Vec::new()
                },
                usage: Usage {
                    prompt_tokens: 1,
                    completion_tokens: 1,
                    total_tokens: 2,
                    cached_tokens: None,
                    cache_read_input_tokens: None,
                    cache_creation_input_tokens: None,
                },
            };
            Ok(crate::providers::provider_stream_for_test(response))
        }

        fn ptype(&self) -> &str {
            "test"
        }

        fn name(&self) -> &str {
            "tool-media-test"
        }

        fn model_id(&self) -> &str {
            "vision-test"
        }
    }

    #[tokio::test]
    async fn file_read_media_reaches_the_next_model_iteration() {
        use std::io::Write;

        let mut image = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
        image.write_all(b"\x89PNG\r\n\x1a\nminimal").unwrap();
        let provider = Arc::new(ToolMediaProvider {
            image_path: image.path().to_string_lossy().into_owned(),
            tool_name: "file_read".to_string(),
            requests: std::sync::Mutex::new(Vec::new()),
        });
        let tools = Arc::new(ToolRegistry::new());
        tools.register(FileReadTool::new());
        let agent = AgentLoop::with_provider_and_tools(
            provider.clone(),
            tools,
            2,
            "vision-test".to_string(),
            std::env::current_dir().unwrap(),
            vec!["text".to_string(), "image".to_string()],
        );

        let (controller, emitter, _) = TurnController::start("session", "assistant-id");
        let turn = controller.snapshot();
        let result = agent
            .process_streaming(
                vec![ChatMessage::user("inspect the image")],
                AgentTurnContext::new(turn.id.0.clone(), turn.message_id.clone(), emitter),
            )
            .await
            .unwrap();

        assert_eq!(result.final_response.content, "image seen");
        assert!(result.final_response.media_refs.is_empty());
        let requests = provider.requests.lock().unwrap();
        assert_eq!(requests.len(), 2);
        let tool_result = requests[1]
            .messages
            .iter()
            .find(|message| message.role == "tool")
            .unwrap();
        assert!(
            tool_result
                .content
                .iter()
                .any(|block| matches!(block, ContentBlock::ImageUrl { .. }))
        );
        assert!(result.emitted_messages.iter().any(|message| {
            message.role == "tool"
                && message
                    .media_refs
                    .iter()
                    .any(|media| media.media_type == "image")
        }));
        assert!(controller.snapshot().blocks.iter().any(|block| matches!(
            block,
            TurnBlock::Tool {
                id,
                status: crate::session::ToolStatus::Completed,
                ..
            } if id == "call-image"
        )));
    }

    struct UserVisibleMediaTool {
        image_path: String,
    }

    #[async_trait::async_trait]
    impl Tool for UserVisibleMediaTool {
        fn name(&self) -> &str {
            "user_visible_media"
        }

        fn description(&self) -> &str {
            "Return an image to both the model and user"
        }

        fn parameters_schema(&self) -> serde_json::Value {
            serde_json::json!({"type": "object", "properties": {}})
        }

        async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
            Ok(ToolResult {
                success: true,
                output: "image ready".to_string(),
                error: None,
            })
        }

        async fn execute_output(&self, args: serde_json::Value) -> anyhow::Result<ToolOutput> {
            Ok(ToolOutput {
                result: self.execute(args).await?,
                artifacts: vec![ToolArtifact::model_and_user(MediaRef {
                    path: self.image_path.clone(),
                    media_type: "image".to_string(),
                })],
            })
        }
    }

    #[tokio::test]
    async fn user_visible_tool_media_reaches_model_and_final_reply_once() {
        use std::io::Write;

        let mut image = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
        image.write_all(b"\x89PNG\r\n\x1a\nminimal").unwrap();
        let image_path = image.path().to_string_lossy().into_owned();
        let provider = Arc::new(ToolMediaProvider {
            image_path: image_path.clone(),
            tool_name: "user_visible_media".to_string(),
            requests: std::sync::Mutex::new(Vec::new()),
        });
        let tools = Arc::new(ToolRegistry::new());
        tools.register(UserVisibleMediaTool {
            image_path: image_path.clone(),
        });
        let agent = AgentLoop::with_provider_and_tools(
            provider.clone(),
            tools,
            2,
            "vision-test".to_string(),
            std::env::current_dir().unwrap(),
            vec!["text".to_string(), "image".to_string()],
        );

        let result = agent
            .process(vec![ChatMessage::user("show me the image")])
            .await
            .unwrap();

        assert_eq!(result.final_response.content, "image seen");
        assert_eq!(result.final_response.media_refs.len(), 1);
        assert_eq!(result.final_response.media_refs[0].path, image_path);
        let final_emitted = result.emitted_messages.last().unwrap();
        assert_eq!(final_emitted.id, result.final_response.id);
        assert_eq!(final_emitted.media_refs.len(), 1);

        let requests = provider.requests.lock().unwrap();
        let tool_result = requests[1]
            .messages
            .iter()
            .find(|message| message.role == "tool")
            .unwrap();
        assert!(
            tool_result
                .content
                .iter()
                .any(|block| matches!(block, ContentBlock::ImageUrl { .. }))
        );
    }

    #[tokio::test]
    async fn tool_media_remains_visible_when_steering_follows_tool_batch() {
        use std::io::Write;

        let mut image = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
        image.write_all(b"\x89PNG\r\n\x1a\nminimal").unwrap();
        let provider = Arc::new(ToolMediaProvider {
            image_path: image.path().to_string_lossy().into_owned(),
            tool_name: "file_read".to_string(),
            requests: std::sync::Mutex::new(Vec::new()),
        });
        let tools = Arc::new(ToolRegistry::new());
        tools.register(FileReadTool::new());
        let agent = AgentLoop::with_provider_and_tools(
            provider.clone(),
            tools,
            2,
            "vision-test".to_string(),
            std::env::current_dir().unwrap(),
            vec!["text".to_string(), "image".to_string()],
        );

        let mailbox = Arc::new(TurnMailbox::new());
        mailbox
            .try_push_user(crate::agent::TurnInput::user(
                "steer-2",
                "also explain what you found",
                Vec::new(),
                None,
                1_000,
            ))
            .unwrap();
        let (controller, emitter, _) = TurnController::start("session", "assistant-id");
        let turn = controller.snapshot();
        let context = AgentTurnContext::new_with_steering(
            turn.id.0.clone(),
            turn.message_id.clone(),
            emitter,
            mailbox,
        );
        let result = agent
            .process_streaming(vec![ChatMessage::user("inspect the image")], context)
            .await
            .unwrap();

        assert_eq!(result.final_response.content, "image seen");
        let requests = provider.requests.lock().unwrap();
        assert_eq!(requests.len(), 2);
        let second_messages = &requests[1].messages;
        let tool_index = second_messages
            .iter()
            .position(|message| message.role == "tool")
            .unwrap();
        assert!(
            second_messages[tool_index]
                .content
                .iter()
                .any(|block| matches!(block, ContentBlock::ImageUrl { .. }))
        );
        assert!(
            second_messages[tool_index + 1..]
                .iter()
                .any(|message| message.role == "user"
                    && message.content.iter().any(|block| matches!(
                        block,
                        ContentBlock::Text { text } if text.contains("also explain what you found")
                    )))
        );
    }

    #[test]
    fn test_should_execute_in_parallel_single_tool() {
        // Would need a proper setup with AgentLoop to test fully
        // For now, just verify the logic: single tool should return false
        let calls = [ToolCall {
            id: "1".to_string(),
            name: "test".to_string(),
            arguments: serde_json::json!({}),
        }];

        // If there's only 1 tool, should return false regardless
        assert!(calls.len() <= 1);
    }

    #[test]
    fn test_chat_message_to_llm_message_preserves_assistant_tool_calls() {
        use crate::providers::Message;

        let chat_message = ChatMessage::assistant_with_tool_calls(
            "calling tool",
            vec![ToolCall {
                id: "call_1".to_string(),
                name: "calculator".to_string(),
                arguments: serde_json::json!({ "expression": "2+2" }),
            }],
        );

        let content = vec![ContentBlock::text(&chat_message.content)];
        let provider_message = Message {
            role: chat_message.role.clone(),
            content,
            reasoning_content: None,
            provider_state: None,
            tool_call_id: chat_message.tool_call_id.clone(),
            name: chat_message.tool_name.clone(),
            tool_calls: chat_message.tool_calls.clone(),
        };

        assert_eq!(provider_message.role, "assistant");
        assert_eq!(provider_message.tool_calls.as_ref().unwrap().len(), 1);
        assert_eq!(
            provider_message.tool_calls.as_ref().unwrap()[0].id,
            "call_1"
        );
        assert_eq!(
            provider_message.tool_calls.as_ref().unwrap()[0].name,
            "calculator"
        );
    }

    #[test]
    fn test_build_content_blocks_keeps_text_with_media() {
        let registry = MediaHandlerRegistry::new();
        let blocks = build_content_blocks(
            "先看这段文字",
            &[MediaRef {
                path: "missing.png".to_string(),
                media_type: "image".to_string(),
            }],
            &[],
            &registry,
            MediaOrigin::User,
        );

        assert_eq!(blocks.len(), 1);
        assert!(matches!(blocks.first(), Some(ContentBlock::Text { text })
                if text.starts_with("先看这段文字\n\n")
                    && text.contains("随本条用户消息同时提交的附件")
                    && text.contains("missing.png")
                    && text.contains("\"media_type\":\"image\"")
                    && text.contains("content is not embedded in this model request")));
    }

    #[test]
    fn test_build_content_blocks_describes_attachment_only_message() {
        let registry = MediaHandlerRegistry::new();
        let blocks = build_content_blocks(
            "",
            &[MediaRef {
                path: "/tmp/report.docx".to_string(),
                media_type: "file".to_string(),
            }],
            &[],
            &registry,
            MediaOrigin::User,
        );

        assert_eq!(blocks.len(), 1);
        assert!(matches!(blocks.first(), Some(ContentBlock::Text { text })
                if text.starts_with("[用户发送了以下附件")
                    && text.contains("report.docx")
                    && text.contains("application/vnd.openxmlformats-officedocument.wordprocessingml.document")
                    && text.contains("\"extension\":\"docx\"")
                    && text.contains("\"size_bytes\":null")
                    && text.contains("content is not embedded in this model request")));
    }

    #[test]
    fn test_build_content_blocks_includes_path_for_supported_images() {
        use std::io::Write;

        let mut image = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
        image.write_all(b"\x89PNG\r\n\x1a\nminimal").unwrap();
        let path = image.path().to_string_lossy().into_owned();
        let registry = MediaHandlerRegistry::with_defaults();

        let blocks = build_content_blocks(
            "分析图片",
            &[MediaRef {
                path: path.clone(),
                media_type: "image".to_string(),
            }],
            &["image".to_string()],
            &registry,
            MediaOrigin::User,
        );

        assert!(matches!(blocks.first(), Some(ContentBlock::Text { text })
                if text.starts_with("分析图片\n\n")
                    && text.contains("随本条用户消息同时提交的附件")
                    && text.contains(&path)
                    && text.contains("model-native content block")));
        assert!(matches!(blocks.get(1), Some(ContentBlock::ImageUrl { .. })));
    }

    #[test]
    fn test_build_content_blocks_labels_tool_media_without_user_wording() {
        use std::io::Write;

        let mut image = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
        image.write_all(b"\x89PNG\r\n\x1a\nminimal").unwrap();
        let path = image.path().to_string_lossy().into_owned();
        let registry = MediaHandlerRegistry::with_defaults();

        let blocks = build_content_blocks(
            "Image file ready for visual inspection.",
            &[MediaRef {
                path,
                media_type: "image".to_string(),
            }],
            &["image".to_string()],
            &registry,
            MediaOrigin::Tool("file_read"),
        );

        assert!(matches!(blocks.first(), Some(ContentBlock::Text { text })
                if text.contains("工具 file_read 返回了以下附件")
                    && !text.contains("用户消息")));
        assert!(matches!(blocks.get(1), Some(ContentBlock::ImageUrl { .. })));
    }

    #[test]
    fn assistant_attachments_replay_as_text_without_native_image_blocks() {
        use std::io::Write;

        let mut image = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
        image.write_all(b"\x89PNG\r\n\x1a\nminimal").unwrap();
        let path = image.path().to_string_lossy().into_owned();
        let provider = Arc::new(ToolMediaProvider {
            image_path: path.clone(),
            tool_name: "file_read".to_string(),
            requests: std::sync::Mutex::new(Vec::new()),
        });
        let agent = AgentLoop::with_provider_and_tools(
            provider,
            Arc::new(ToolRegistry::new()),
            1,
            "vision-test".to_string(),
            std::env::current_dir().unwrap(),
            vec!["text".to_string(), "image".to_string()],
        );
        let mut assistant = ChatMessage::assistant("截图已发送");
        assistant.media_refs = vec![MediaRef {
            path: path.clone(),
            media_type: "image".to_string(),
        }];

        let converted = agent.chat_message_to_llm_message(&assistant, true);

        assert_eq!(converted.role, "assistant");
        assert_eq!(converted.content.len(), 1);
        assert!(
            matches!(converted.content.first(), Some(ContentBlock::Text { text })
                if text.contains("截图已发送")
                    && text.contains(&path)
                    && text.contains("not embedded in this model request"))
        );
        assert!(
            !converted
                .content
                .iter()
                .any(|block| matches!(block, ContentBlock::ImageUrl { .. }))
        );
    }

    #[test]
    fn only_the_trailing_tool_batch_replays_tool_media() {
        let mut messages = vec![
            ChatMessage::assistant_with_tool_calls(
                "",
                vec![ToolCall {
                    id: "old-call".to_string(),
                    name: "file_read".to_string(),
                    arguments: serde_json::json!({}),
                }],
            ),
            ChatMessage::tool_with_media(
                "old-call",
                "file_read",
                "old",
                vec![MediaRef {
                    path: "/tmp/old.png".to_string(),
                    media_type: "image".to_string(),
                }],
            ),
            ChatMessage::assistant("continue"),
            ChatMessage::assistant_with_tool_calls(
                "",
                vec![ToolCall {
                    id: "new-call".to_string(),
                    name: "file_read".to_string(),
                    arguments: serde_json::json!({}),
                }],
            ),
            ChatMessage::tool_with_media(
                "new-call",
                "file_read",
                "new",
                vec![MediaRef {
                    path: "/tmp/new.png".to_string(),
                    media_type: "image".to_string(),
                }],
            ),
        ];

        assert!(!should_include_message_media(&messages, 1));
        assert!(should_include_message_media(&messages, 4));

        messages.push(ChatMessage::user("next turn"));
        // A same-turn steering message is a real user message, but it must
        // not hide media returned by the immediately preceding tool batch.
        assert!(should_include_message_media(&messages, 4));

        messages.push(ChatMessage::assistant("final"));
        assert!(!should_include_message_media(&messages, 4));
    }

    struct HangingStreamProvider;

    #[async_trait::async_trait]
    impl LLMProvider for HangingStreamProvider {
        async fn stream(
            &self,
            _request: ChatCompletionRequest,
        ) -> Result<ProviderStream, crate::providers::DynProviderError> {
            Ok(Box::pin(futures_util::stream::pending()))
        }

        fn ptype(&self) -> &str {
            "test"
        }

        fn name(&self) -> &str {
            "hanging-stream"
        }

        fn model_id(&self) -> &str {
            "hanging-stream"
        }
    }

    struct HangingConnectProvider {
        connected: std::sync::atomic::AtomicBool,
    }

    #[async_trait::async_trait]
    impl LLMProvider for HangingConnectProvider {
        async fn stream(
            &self,
            _request: ChatCompletionRequest,
        ) -> Result<ProviderStream, crate::providers::DynProviderError> {
            self.connected
                .store(true, std::sync::atomic::Ordering::SeqCst);
            futures_util::future::pending::<()>().await;
            unreachable!("connect must be cancelled before completing")
        }

        fn ptype(&self) -> &str {
            "test"
        }

        fn name(&self) -> &str {
            "hanging-connect"
        }

        fn model_id(&self) -> &str {
            "hanging-connect"
        }
    }

    struct BarrierTool {
        barrier: Arc<tokio::sync::Notify>,
        read_only: bool,
    }

    #[async_trait::async_trait]
    impl Tool for BarrierTool {
        fn name(&self) -> &str {
            "hang"
        }

        fn description(&self) -> &str {
            "waits until the test releases the barrier"
        }

        fn parameters_schema(&self) -> serde_json::Value {
            serde_json::json!({ "type": "object" })
        }

        fn read_only(&self) -> bool {
            self.read_only
        }

        async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
            self.barrier.notified().await;
            Ok(ToolResult {
                success: true,
                output: "late result".to_string(),
                error: None,
            })
        }
    }

    struct ToolCallThenHangProvider {
        tool_calls: usize,
    }

    #[async_trait::async_trait]
    impl LLMProvider for ToolCallThenHangProvider {
        async fn stream(
            &self,
            _request: ChatCompletionRequest,
        ) -> Result<ProviderStream, crate::providers::DynProviderError> {
            let mut chunks = vec![ProviderChunk::Metadata {
                id: "tool-call".into(),
                model: "tool-call-hang".into(),
            }];
            for index in 0..self.tool_calls {
                chunks.push(ProviderChunk::ToolCallStart {
                    index,
                    id: Some(format!("call-{index}")),
                    name: Some("hang".to_string()),
                });
                chunks.push(ProviderChunk::ToolCallArguments {
                    index,
                    delta: "{}".to_string(),
                });
            }
            chunks.push(ProviderChunk::Done(FinishReason::ToolCalls));
            Ok(Box::pin(futures_util::stream::iter(
                chunks.into_iter().map(Ok),
            )))
        }

        fn ptype(&self) -> &str {
            "test"
        }

        fn name(&self) -> &str {
            "tool-call-hang"
        }

        fn model_id(&self) -> &str {
            "tool-call-hang"
        }
    }

    fn cancellation_agent(provider: Arc<dyn LLMProvider>, tools: Arc<ToolRegistry>) -> AgentLoop {
        AgentLoop::with_provider_and_tools(
            provider,
            tools,
            4,
            "cancel-test".to_string(),
            std::env::temp_dir(),
            vec!["text".to_string()],
        )
    }

    #[tokio::test]
    async fn pre_cancelled_token_returns_cancelled_before_provider() {
        let provider = Arc::new(HangingStreamProvider);
        let agent = cancellation_agent(provider.clone(), Arc::new(ToolRegistry::new()));
        let context = ToolExecutionContext::default();
        context.cancellation.cancel();

        let error = agent
            .process_with_context(vec![ChatMessage::user("work")], context)
            .await
            .unwrap_err();
        assert!(matches!(error, AgentError::Cancelled));
    }

    #[tokio::test]
    async fn cancellation_during_provider_stream_returns_cancelled() {
        let agent = cancellation_agent(
            Arc::new(HangingStreamProvider),
            Arc::new(ToolRegistry::new()),
        );
        let context = ToolExecutionContext::default();
        let token = context.cancellation.clone();

        let process = tokio::spawn(async move {
            agent
                .process_with_context(vec![ChatMessage::user("work")], context)
                .await
        });
        tokio::time::sleep(Duration::from_millis(50)).await;
        token.cancel();

        let error = tokio::time::timeout(Duration::from_secs(5), process)
            .await
            .expect("cancellation must end the provider stream promptly")
            .unwrap()
            .unwrap_err();
        assert!(matches!(error, AgentError::Cancelled));
    }

    #[tokio::test]
    async fn cancellation_during_provider_connect_returns_cancelled() {
        let provider = Arc::new(HangingConnectProvider {
            connected: std::sync::atomic::AtomicBool::new(false),
        });
        let agent = cancellation_agent(provider.clone(), Arc::new(ToolRegistry::new()));
        let context = ToolExecutionContext::default();
        let token = context.cancellation.clone();

        let process = tokio::spawn(async move {
            agent
                .process_with_context(vec![ChatMessage::user("work")], context)
                .await
        });
        tokio::time::sleep(Duration::from_millis(50)).await;
        token.cancel();

        let error = tokio::time::timeout(Duration::from_secs(5), process)
            .await
            .expect("cancellation must interrupt the provider connect")
            .unwrap()
            .unwrap_err();
        assert!(matches!(error, AgentError::Cancelled));
        assert!(provider.connected.load(std::sync::atomic::Ordering::SeqCst));
    }

    #[tokio::test]
    async fn cancellation_during_tool_batch_returns_cancelled_and_discards_late_result() {
        let barrier = Arc::new(tokio::sync::Notify::new());
        let tools = Arc::new(ToolRegistry::new());
        tools.register(BarrierTool {
            barrier: barrier.clone(),
            read_only: false,
        });
        let agent = cancellation_agent(Arc::new(ToolCallThenHangProvider { tool_calls: 1 }), tools);
        let context = ToolExecutionContext::default();
        let token = context.cancellation.clone();

        let process = tokio::spawn(async move {
            agent
                .process_with_context(vec![ChatMessage::user("work")], context)
                .await
        });
        tokio::time::sleep(Duration::from_millis(100)).await;
        token.cancel();

        let error = tokio::time::timeout(Duration::from_secs(5), process)
            .await
            .expect("cancellation must end the tool batch promptly")
            .unwrap()
            .unwrap_err();
        assert!(matches!(error, AgentError::Cancelled));

        // The cancelled batch dropped its tool futures.  Releasing the
        // barrier afterwards must not surface the late result anywhere.
        barrier.notify_waiters();
        tokio::time::sleep(Duration::from_millis(20)).await;
    }

    #[tokio::test]
    async fn cancellation_during_parallel_tool_batch_returns_cancelled() {
        let barrier = Arc::new(tokio::sync::Notify::new());
        let tools = Arc::new(ToolRegistry::new());
        tools.register(BarrierTool {
            barrier: barrier.clone(),
            read_only: true,
        });
        let agent = cancellation_agent(Arc::new(ToolCallThenHangProvider { tool_calls: 2 }), tools);
        let context = ToolExecutionContext::default();
        let token = context.cancellation.clone();

        let process = tokio::spawn(async move {
            agent
                .process_with_context(vec![ChatMessage::user("work")], context)
                .await
        });
        tokio::time::sleep(Duration::from_millis(100)).await;
        token.cancel();

        let error = tokio::time::timeout(Duration::from_secs(5), process)
            .await
            .expect("cancellation must end the parallel tool batch promptly")
            .unwrap()
            .unwrap_err();
        assert!(matches!(error, AgentError::Cancelled));
    }
}

#[derive(Debug)]
pub enum AgentError {
    ProviderCreation(String),
    LlmError(String),
    /// The Provider rejected a request because it exceeded its context
    /// window. `tool_progress` is true once this invocation has executed a
    /// tool, in which case Session must never restart the Turn from durable
    /// history because doing so could repeat side effects.
    ContextOverflow {
        message: String,
        parsed_window: Option<usize>,
        tool_progress: bool,
    },
    /// The run was cancelled by `/stop`, a parent run, timeout ownership or
    /// shutdown.  Terminal state must be decided by this variant, never by
    /// matching error strings.
    Cancelled,
    /// The run exceeded its deadline.
    TimedOut,
    Other(String),
}

impl AgentError {
    pub fn is_cancelled(&self) -> bool {
        matches!(self, AgentError::Cancelled)
    }
}

impl std::fmt::Display for AgentError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AgentError::ProviderCreation(e) => write!(f, "Provider creation error: {}", e),
            AgentError::LlmError(e) => write!(f, "LLM error: {}", e),
            AgentError::ContextOverflow { message, .. } => {
                write!(f, "LLM context overflow: {message}")
            }
            AgentError::Cancelled => write!(f, "agent run cancelled"),
            AgentError::TimedOut => write!(f, "agent run timed out"),
            AgentError::Other(e) => write!(f, "{}", e),
        }
    }
}

impl std::error::Error for AgentError {}
