Skip to main content

talos_agent/
lib.rs

1//! Talos agent — core orchestration logic and the agent turn loop.
2//!
3//! The agent manages a conversation turn with an LLM provider, executing tool
4//! calls when the model requests them and feeding results back until a final
5//! text response is produced.
6//!
7//! # Security Pipeline
8//!
9//! Every tool call goes through a security pipeline:
10//! 1. **Permission check** — the [`PermissionEngine`] evaluates the call
11//! 2. **Sandbox execution** — bash tools run through the sandbox when available
12//! 3. **Execute** — the tool is invoked directly
13//! 4. **Retry on denial** — denied calls return an error result
14//!
15//! The `Ask` decision defaults to `Deny` at the agent level. Both the CLI layer
16//! and an embedded runtime may bridge `Ask` to an interactive approval handler;
17//! with no approval handler configured, `Ask` still fails closed (`Deny`).
18//!
19//! # Support Boundary
20//!
21//! This crate owns the **turn-loop implementation**. It may be published to
22//! crates.io only to satisfy the `talos-runtime` dependency closure under
23//! [ADR-052](../../docs/decisions/052-sdk-publication-and-composition-boundary.md)
24//! (route A). It is **not** a recommended or supported SDK entrypoint.
25//!
26//! - Embedders should use `talos_runtime::RuntimeBuilder` (in the `talos-runtime`
27//!   facade crate) to construct a safe runtime that wraps permission, approval,
28//!   and sandbox policy.
29//! - Direct users of `talos-agent` bypass that wrapping and are themselves
30//!   responsible for installing equivalent permission rules, an approval
31//!   handler, and a sandbox policy.
32//! - Its public constructors and configuration methods are NOT covered by the
33//!   runtime SDK contract and may change more frequently than the facade
34//!   surface during the pre-1.0 period.
35//!
36//! See `docs/reference/RUNTIME-SDK-CONTRACT.md` for the supported embedding
37//! surface.
38
39pub mod compaction;
40pub mod compression;
41pub mod token;
42mod tool_output;
43
44use std::collections::HashMap;
45use std::collections::HashSet;
46use std::path::PathBuf;
47use std::sync::Arc;
48
49pub mod caching;
50mod configuration;
51pub mod context;
52mod helpers;
53pub mod prompt;
54mod request_plan;
55mod scheduler;
56pub mod session;
57mod tool_execution;
58
59pub use scheduler::{
60    PendingSchedulerActor, create_delay_tool_and_scheduler, create_scheduler_tools,
61};
62
63use talos_core::message::{
64    AgentEvent, AssistantReasoning, Message, MessageToolResult, ReasoningBlock, StopReason,
65    ToolCall,
66};
67use talos_core::provider::{LanguageModel, ProviderError};
68use talos_core::tool::{ToolPresentationPolicy, ToolProvenance, ToolRegistry};
69use talos_permission::PermissionEngine;
70use talos_plugin::{
71    BudgetKind, HookContext, HookEvent, HookOutcome, HookRegistry, ToolObservation, TurnId,
72    TurnStatus,
73};
74use talos_sandbox::SandboxProvider;
75use thiserror::Error;
76use tokio::sync::mpsc;
77
78use crate::compression::BashOutputCompressor;
79use crate::configuration::describe_presented_tools;
80
81pub use compression::{CompressionMetrics, RetrievalMetrics};
82pub use prompt::{ActivatedSkillContext, ContextFile, SystemPromptBuilder, ToolDescription};
83pub(crate) use request_plan::PreparedSessionTurn;
84
85/// Maximum number of tool calls allowed per turn before budget exhaustion.
86const MAX_TOOL_CALLS_PER_TURN: usize = 50;
87
88/// Maximum number of concurrent read-only tool executions.
89const MAX_CONCURRENT_READ_ONLY: usize = 10;
90
91/// Threshold for doom loop detection — same tool+args this many times triggers
92/// an early stop.
93const DOOM_LOOP_THRESHOLD: u32 = 3;
94
95fn should_compress_shell_output(tool_name: &str) -> bool {
96    matches!(tool_name, "bash" | "powershell")
97}
98
99/// Shared admission contract for one complete Provider request.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub struct RequestBudgetSpec {
102    /// Exact output token limit requested in the Provider body.
103    pub requested_output_tokens: u32,
104    /// Conservative margin applied to approximate text/tool/image input cost.
105    pub input_safety_margin_bps: u16,
106    /// Fixed parser/protocol overhead added after the proportional margin.
107    pub fixed_overhead_tokens: u32,
108}
109
110impl RequestBudgetSpec {
111    #[must_use]
112    pub const fn new(requested_output_tokens: u32) -> Self {
113        Self {
114            requested_output_tokens,
115            input_safety_margin_bps: 2_500,
116            fixed_overhead_tokens: 256,
117        }
118    }
119}
120
121impl Default for RequestBudgetSpec {
122    fn default() -> Self {
123        Self::new(4096)
124    }
125}
126
127#[derive(Debug, Clone)]
128struct PendingToolCall {
129    call: ToolCall,
130    provenance: ToolProvenance,
131}
132
133/// Errors that can occur during agent execution.
134#[derive(Debug, Error)]
135pub enum AgentError {
136    /// An error from the underlying LLM provider.
137    #[error("provider error: {0}")]
138    ProviderError(#[from] ProviderError),
139
140    /// The turn was cancelled via [`CancellationToken`].
141    #[error("turn cancelled")]
142    Cancelled,
143
144    /// An unexpected event sequence was received.
145    #[error("unexpected event: {0}")]
146    UnexpectedEvent(String),
147
148    /// A tool-related error occurred (lookup failure, execution panic, etc.).
149    #[error("tool error: {0}")]
150    ToolError(String),
151
152    /// The turn exceeds the maximum allowed tool call budget.
153    #[error("turn budget exceeded: maximum of {MAX_TOOL_CALLS_PER_TURN} tool calls per turn")]
154    TurnBudgetExceeded,
155
156    /// The next provider request would exceed the configured model context.
157    #[error("request context budget exceeded: estimated {estimated} tokens, limit {limit}")]
158    ContextBudgetExceeded {
159        /// Estimated request tokens, including tool definitions and output reserve.
160        estimated: u32,
161        /// Configured model context limit.
162        limit: u32,
163    },
164
165    /// A potential doom loop was detected — the same tool was called with
166    /// identical arguments multiple times in a single turn.
167    #[error("doom loop detected: {0}")]
168    DoomLoopDetected(String),
169
170    /// A hook denied the current operation.
171    #[error("hook denied operation: {0}")]
172    HookDenied(String),
173}
174
175/// Result alias for agent operations.
176pub type AgentResult<T> = Result<T, AgentError>;
177
178/// Controls what happens when a configured sandbox is unavailable.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
180pub enum SandboxFallbackPolicy {
181    /// Reject the invocation when isolation is unavailable.
182    #[default]
183    Deny,
184    /// Ask a dedicated fallback handler for a one-invocation approval.
185    Ask,
186    /// Continue without isolation after permission has already allowed the tool.
187    AllowUnsandboxed,
188}
189
190/// A redacted, typed request for a sandbox fallback decision.
191#[derive(Debug, Clone, PartialEq)]
192pub struct SandboxFallbackContext {
193    /// The tool requiring the fallback.
194    pub tool_name: String,
195    /// Observer-safe tool input for the approval surface.
196    pub arguments: serde_json::Value,
197    /// Stable summary fields for display or audit projection.
198    pub summary_fields: Vec<String>,
199}
200
201/// A dedicated sandbox fallback decision. It intentionally has no permanent
202/// or reusable approval variant.
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum SandboxFallbackDecision {
205    /// Approve only the current invocation.
206    ApproveOnce,
207    /// Reject the fallback.
208    Deny,
209}
210
211/// Resolves typed sandbox fallback requests independently of normal tool
212/// permission approval.
213#[async_trait::async_trait]
214pub trait SandboxFallbackHandler: Send + Sync {
215    /// Decides whether this one fallback invocation may proceed.
216    async fn request_fallback(&self, context: SandboxFallbackContext) -> SandboxFallbackDecision;
217}
218
219// Callback type for memory prompt injection.
220type MemoryProviderCallback = dyn Fn(&str) -> Option<String> + Send + Sync;
221// Callback type for bounded session todo prompt injection.
222type TodoSectionProviderCallback = dyn Fn() -> Option<String> + Send + Sync;
223
224/// The agent orchestrates a conversation turn: takes a user message, calls the
225/// LLM provider, streams events, executes tool calls when requested, and feeds
226/// results back until a final text response is produced.
227///
228/// # Security Pipeline
229///
230/// When a permission engine is configured, every tool call is evaluated before
231/// execution. Denied calls return an error result without invoking the tool.
232/// The `Ask` decision defaults to `Deny` at the agent level.
233///
234/// When a sandbox is configured, bash tool calls are executed within the
235/// sandbox environment. If the sandbox is unavailable, the configured
236/// [`SandboxFallbackPolicy`] decides whether the invocation is denied or may
237/// continue through an explicitly approved unsandboxed path.
238///
239/// # Example
240///
241/// ```no_run
242/// use talos_agent::Agent;
243/// use talos_core::tool::ToolRegistry;
244/// use std::sync::Arc;
245/// # use talos_core::provider::{LanguageModel, ProviderResult, Receiver};
246/// # use talos_core::message::{AgentEvent, Message};
247/// # struct MyModel;
248/// # #[async_trait::async_trait]
249/// # impl LanguageModel for MyModel {
250/// #     async fn stream(&self, _: &[Message]) -> ProviderResult<Receiver<AgentEvent>> { unimplemented!() }
251/// # }
252/// # async fn example() {
253/// let provider: Arc<dyn LanguageModel> = Arc::new(MyModel);
254/// let tools = ToolRegistry::new();
255/// let agent = Agent::new(provider, tools);
256/// let response = agent.run("Hello!".into()).await.unwrap();
257/// # }
258/// ```
259pub struct Agent {
260    provider: Arc<dyn LanguageModel>,
261    tools: ToolRegistry,
262    /// Optional permission engine for gating tool execution.
263    permission_engine: Option<Arc<PermissionEngine>>,
264    /// Optional sandbox provider for bash tool execution.
265    sandbox: Option<Arc<dyn SandboxProvider>>,
266    /// Policy used only when a configured sandbox reports unavailable.
267    sandbox_fallback_policy: SandboxFallbackPolicy,
268    /// Dedicated handler for one-shot sandbox fallback approval.
269    sandbox_fallback_handler: Option<Arc<dyn SandboxFallbackHandler>>,
270    /// Workspace root directory, used for sandbox configuration.
271    workspace_root: PathBuf,
272    /// Builder for assembling the system prompt.
273    prompt_builder: SystemPromptBuilder,
274    /// Per-agent lifecycle hook registry.
275    hook_registry: Arc<HookRegistry>,
276    /// Workspace context (AGENTS.md, history summary) for Context message.
277    workspace_context: Option<String>,
278    /// Cached tool definitions for native API calls.
279    tool_definitions: Vec<talos_core::provider::ToolDefinition>,
280    /// Names of tools currently presented to the provider.
281    presented_tool_names: HashSet<String>,
282    /// Whether execution is restricted to provider-presented tools.
283    enforce_tool_presentation_policy: bool,
284    /// Current model-facing tool presentation policy.
285    tool_presentation_policy: ToolPresentationPolicy,
286    /// Cached stable prefix (Identity + Tools + Skills) computed once and
287    /// reused across turns. Invalidated when tools, skills, or identity change.
288    cached_stable_prefix: std::sync::Mutex<Option<String>>,
289    /// Optional memory provider callback for injecting memory into prompts.
290    memory_provider: Option<Arc<MemoryProviderCallback>>,
291    /// Optional provider callback for injecting bounded active session todos.
292    todo_section_provider: Option<Arc<TodoSectionProviderCallback>>,
293    /// Config provider key for reasoning origin stamping and replay gating.
294    provider_key: Option<String>,
295    /// Model id for reasoning origin stamping and replay gating.
296    model_id: Option<String>,
297    /// Whether to replay reasoning in request history (ADR-034 replay policy).
298    replay_reasoning: bool,
299    /// When true, bash tool output exceeding the line threshold is compressed
300    /// before entering model context. Default: false.
301    bash_compression_enabled: bool,
302    tool_output_threshold: usize,
303    /// Whether the active model supports image input. When false, the
304    /// `read_image` tool is registered but not presented to the model
305    /// (ADR-051 / I154 capability gate).
306    image_input_supported: bool,
307    /// Exact output reserve and conservative input-estimation policy.
308    request_budget_spec: RequestBudgetSpec,
309}
310impl Agent {
311    pub fn provider(&self) -> &dyn LanguageModel {
312        self.provider.as_ref()
313    }
314
315    /// Runs a single turn with the given user message and returns the complete
316    /// assistant response.
317    ///
318    /// If the model emits tool calls during the turn, they are executed and
319    /// results are fed back until the model produces a final text response.
320    /// [`AgentError::TurnBudgetExceeded`] if the tool call budget is exceeded,
321    /// or [`AgentError::DoomLoopDetected`] if a doom loop is detected.
322    pub async fn run(&self, user_message: String) -> AgentResult<String> {
323        let (result, _) = self.run_inner(user_message, vec![], None, None).await;
324        result
325    }
326
327    /// Runs a single turn with streaming events forwarded to the given
328    /// unbounded mpsc channel.
329    ///
330    /// This method behaves like [`Agent::run`] but also sends every
331    /// [`AgentEvent`] to `event_tx`, allowing external consumers to receive
332    /// real-time updates (e.g., for UI streaming).
333    ///
334    /// # Arguments
335    ///
336    /// * `user_message` — The current user message for this turn.
337    /// * `history` — Prior conversation messages to include before the user message.
338    /// * `event_tx` — Channel for streaming agent events.
339    ///
340    /// # Errors
341    ///
342    /// Returns the same errors as [`Agent::run`].
343    pub async fn run_streaming(
344        &self,
345        user_message: String,
346        history: Vec<Message>,
347        event_tx: mpsc::UnboundedSender<AgentEvent>,
348    ) -> AgentResult<(String, Vec<Message>)> {
349        let (result, messages) = self
350            .run_inner(user_message, history, Some(event_tx), None)
351            .await;
352        result.map(|text| (text, messages))
353    }
354
355    /// Like [`run_streaming`] but returns partial messages even on error,
356    /// enabling the session layer to persist valid completed tool exchanges
357    /// across provider failures (SESSION-006 / I135).
358    ///
359    /// The returned messages are always the normalized slice from
360    /// `persist_start..` — i.e., the user message and any completed
361    /// assistant/tool messages. On error, this may contain a valid prefix
362    /// of completed exchanges that should be persisted; incomplete streamed
363    /// assistant fragments are never included.
364    #[allow(dead_code)]
365    pub(crate) async fn run_for_session_turn(
366        &self,
367        user_message: String,
368        history: Vec<Message>,
369        event_tx: mpsc::UnboundedSender<AgentEvent>,
370    ) -> (AgentResult<String>, Vec<Message>) {
371        self.run_inner(user_message, history, Some(event_tx), None)
372            .await
373    }
374
375    /// Session turn with multimodal content (MODEL-009-D/I152).
376    /// Constructs `Message::Multimodal` instead of `Message::User`
377    /// when image attachments are present.
378    #[allow(dead_code)]
379    pub(crate) async fn run_for_session_turn_multimodal(
380        &self,
381        user_message: String,
382        attachments: Vec<talos_core::message::ContentPart>,
383        history: Vec<Message>,
384        event_tx: mpsc::UnboundedSender<AgentEvent>,
385    ) -> (AgentResult<String>, Vec<Message>) {
386        self.run_inner(
387            user_message,
388            history,
389            Some(event_tx),
390            if attachments.is_empty() {
391                None
392            } else {
393                Some(attachments)
394            },
395        )
396        .await
397    }
398
399    /// Runs one actor turn from an ordered structured submission.
400    ///
401    /// Each item remains a distinct persisted user message. Text projection is
402    /// used only for memory lookup; it is never the authoritative transcript.
403    /// Estimates the initial provider request produced for a structured
404    /// session submission, including dynamic prompt sections, workspace
405    /// context, native tool definitions, multimodal inputs, and an output
406    /// reserve. This remains a diagnostic estimate only; Session execution is
407    /// authorized by the sealed plan returned from `prepare_session_turn`.
408    #[allow(dead_code)]
409    pub(crate) async fn estimate_session_request_tokens(
410        &self,
411        items: &[talos_core::session::SubmissionItem],
412        history: Vec<Message>,
413    ) -> AgentResult<u32> {
414        let memory_query = items
415            .iter()
416            .map(|item| item.text.as_str())
417            .collect::<Vec<_>>()
418            .join("\n");
419        let hook_ctx = HookContext::new(TurnId::new(), self.workspace_root.clone());
420        let (mut messages, _) = self
421            .build_provider_messages(memory_query, history, &hook_ctx)
422            .await?;
423        messages.pop();
424        messages.extend(items.iter().map(|item| {
425            if item.attachments.is_empty() {
426                Message::User {
427                    content: item.text.clone(),
428                }
429            } else {
430                let mut parts = Vec::with_capacity(item.attachments.len() + 1);
431                if !item.text.is_empty() {
432                    parts.push(talos_core::message::ContentPart::Text {
433                        text: item.text.clone(),
434                    });
435                }
436                parts.extend(item.attachments.clone());
437                Message::Multimodal { parts }
438            }
439        }));
440
441        let (_, mut tool_definitions, _) =
442            describe_presented_tools(&self.tools, &self.tool_presentation_policy);
443        if !self.image_input_supported {
444            tool_definitions.retain(|definition| definition.name != "read_image");
445        }
446        Ok(self.estimate_provider_request_tokens(&messages, &tool_definitions))
447    }
448
449    fn estimate_provider_request_tokens(
450        &self,
451        messages: &[Message],
452        tool_definitions: &[talos_core::provider::ToolDefinition],
453    ) -> u32 {
454        let tool_tokens = tool_definitions.iter().fold(0_u32, |total, definition| {
455            total
456                .saturating_add(crate::token::TokenEstimator::estimate_text(
457                    &definition.name,
458                ))
459                .saturating_add(crate::token::TokenEstimator::estimate_text(
460                    &definition.description,
461                ))
462                .saturating_add(crate::token::TokenEstimator::estimate_text(
463                    &definition.parameters.to_string(),
464                ))
465        });
466        let raw_input = crate::token::TokenEstimator::new()
467            .estimate(messages)
468            .saturating_add(tool_tokens);
469        let proportional_margin = u64::from(raw_input)
470            .saturating_mul(u64::from(self.request_budget_spec.input_safety_margin_bps))
471            .div_ceil(10_000);
472        raw_input
473            .saturating_add(u32::try_from(proportional_margin).unwrap_or(u32::MAX))
474            .saturating_add(self.request_budget_spec.fixed_overhead_tokens)
475            .saturating_add(self.request_budget_spec.requested_output_tokens)
476    }
477
478    /// Builds a provider request preview without calling the provider.
479    ///
480    /// This is the explicit diagnostic API used by product layers that expose
481    /// request-inspection commands. The normal turn loop treats all user
482    /// messages literally and does not parse diagnostic magic strings.
483    pub async fn preview_request(
484        &self,
485        user_message: String,
486        history: Vec<Message>,
487    ) -> AgentResult<Option<String>> {
488        let turn_id = TurnId::new();
489        let hook_ctx = HookContext::new(turn_id, self.workspace_root.clone());
490        let (messages, _) = self
491            .build_provider_messages(user_message, history, &hook_ctx)
492            .await?;
493
494        Ok(self.provider.request_preview(&messages).map(|preview| {
495            let snapshot =
496                serde_json::to_string_pretty(&preview).unwrap_or_else(|_| preview.to_string());
497            format!("Request preview (no API call made):\n\n```json\n{snapshot}\n```")
498        }))
499    }
500
501    async fn build_provider_messages(
502        &self,
503        user_message: String,
504        history: Vec<Message>,
505        hook_ctx: &HookContext,
506    ) -> AgentResult<(Vec<Message>, usize)> {
507        let mut prompt_builder = if let Some(ref mem_provider) = self.memory_provider {
508            let memory_section = mem_provider(&user_message);
509            self.prompt_builder
510                .clone()
511                .with_memory_section(memory_section)
512        } else {
513            self.prompt_builder.clone()
514        };
515        if let Some(ref todo_provider) = self.todo_section_provider {
516            prompt_builder = prompt_builder.with_todo_section(todo_provider());
517        }
518
519        let stable_prefix = {
520            let mut cache = self
521                .cached_stable_prefix
522                .lock()
523                .expect("cache lock poisoned");
524            match cache.as_ref() {
525                Some(cached) => cached.clone(),
526                None => {
527                    let prefix = prompt_builder.build_stable_prefix();
528                    *cache = Some(prefix.clone());
529                    prefix
530                }
531            }
532        };
533        let stable_prefix_len = stable_prefix.len();
534        let dynamic_suffix = prompt_builder.build_dynamic_suffix();
535        let combined = if stable_prefix.is_empty() {
536            dynamic_suffix
537        } else if dynamic_suffix.is_empty() {
538            stable_prefix
539        } else {
540            format!("{stable_prefix}\n{dynamic_suffix}")
541        };
542
543        let (system_prompt, cache_markers) = prompt_builder
544            .build_with_hooks_from_prompt(
545                self.hook_registry.as_ref(),
546                hook_ctx,
547                &combined,
548                stable_prefix_len,
549            )
550            .await
551            .map_err(AgentError::HookDenied)?;
552
553        let mut messages = history;
554
555        if !system_prompt.is_empty() {
556            messages.push(Message::System {
557                content: system_prompt,
558                cache_markers,
559            });
560        }
561
562        if let Some(ref context) = self.workspace_context
563            && !context.is_empty()
564        {
565            messages.push(Message::Context {
566                content: context.clone(),
567            });
568        }
569
570        let persist_start = messages.len();
571
572        messages.push(Message::User {
573            content: user_message,
574        });
575
576        Ok((messages, persist_start))
577    }
578
579    /// Like `build_provider_messages` but pushes `Message::Multimodal`
580    /// instead of `Message::User` when attachments are present.
581    #[allow(dead_code)]
582    async fn build_provider_messages_with_attachments(
583        &self,
584        user_message: String,
585        history: Vec<Message>,
586        hook_ctx: &HookContext,
587        attachments: Vec<talos_core::message::ContentPart>,
588    ) -> AgentResult<(Vec<Message>, usize)> {
589        let (mut messages, persist_start) = self
590            .build_provider_messages(user_message, history, hook_ctx)
591            .await?;
592
593        if let Some(Message::User { content: _ }) = messages.last() {
594            let mut parts = Vec::new();
595            if let Some(Message::User { content }) = messages.last_mut()
596                && !content.is_empty()
597            {
598                parts.push(talos_core::message::ContentPart::Text {
599                    text: content.clone(),
600                });
601            }
602            parts.extend(attachments);
603            if let Some(last) = messages.last_mut() {
604                *last = Message::Multimodal { parts };
605            }
606        }
607
608        Ok((messages, persist_start))
609    }
610
611    /// Internal implementation shared by [`run`] and [`run_streaming`].
612    ///
613    /// Executes the full turn loop: user message → provider → tool calls →
614    /// execute → tool results → provider → ... → final response.
615    async fn run_inner(
616        &self,
617        user_message: String,
618        history: Vec<Message>,
619        event_tx: Option<mpsc::UnboundedSender<AgentEvent>>,
620        attachments: Option<Vec<talos_core::message::ContentPart>>,
621    ) -> (AgentResult<String>, Vec<Message>) {
622        let input_messages = if let Some(atts) = attachments {
623            let mut parts = Vec::with_capacity(atts.len() + 1);
624            if !user_message.is_empty() {
625                parts.push(talos_core::message::ContentPart::Text {
626                    text: user_message.clone(),
627                });
628            }
629            parts.extend(atts);
630            vec![Message::Multimodal { parts }]
631        } else {
632            vec![Message::User {
633                content: user_message.clone(),
634            }]
635        };
636        self.run_inner_with_messages(user_message, input_messages, history, event_tx, None)
637            .await
638    }
639
640    async fn run_inner_with_messages(
641        &self,
642        memory_query: String,
643        input_messages: Vec<Message>,
644        history: Vec<Message>,
645        event_tx: Option<mpsc::UnboundedSender<AgentEvent>>,
646        request_context_limit: Option<u32>,
647    ) -> (AgentResult<String>, Vec<Message>) {
648        let prepared = match self
649            .prepare_turn_start(memory_query, input_messages, history, request_context_limit)
650            .await
651        {
652            Ok(prepared) => prepared,
653            Err(error) => return (Err(error), Vec::new()),
654        };
655        self.run_prepared_inner(prepared, event_tx, None).await
656    }
657
658    async fn run_prepared_inner(
659        &self,
660        prepared: PreparedSessionTurn,
661        event_tx: Option<mpsc::UnboundedSender<AgentEvent>>,
662        snapshot_tx: Option<mpsc::UnboundedSender<Vec<Message>>>,
663    ) -> (AgentResult<String>, Vec<Message>) {
664        let PreparedSessionTurn {
665            hook_ctx,
666            mut messages,
667            persist_start,
668            mut active_tool_presentation_policy,
669            mut active_tool_definitions,
670            mut active_presented_tool_names,
671            initial_plan,
672            request_context_limit,
673        } = prepared;
674        let mut total_tool_calls: usize = 0;
675        let mut doom_tracker: HashMap<(String, String), u32> = HashMap::new();
676        let mut pending_continuation_parts: Vec<talos_core::message::ContentPart> = Vec::new();
677        let mut initial_plan = Some(initial_plan);
678
679        if let Some(snapshot_tx) = &snapshot_tx {
680            let _ = snapshot_tx.send(self.persistence_projection(&messages[persist_start..]));
681        }
682
683        let (result, final_status) = 'turn_loop: loop {
684            let plan = if let Some(plan) = initial_plan.take() {
685                plan
686            } else {
687                match self
688                    .seal_provider_request_plan(
689                        &hook_ctx,
690                        &messages,
691                        &active_tool_definitions,
692                        &mut pending_continuation_parts,
693                        request_context_limit,
694                    )
695                    .await
696                {
697                    Ok(plan) => plan,
698                    Err(error) => break (Err(error), TurnStatus::Denied),
699                }
700            };
701            tracing::trace!(
702                estimated_tokens = plan.estimated_tokens,
703                "dispatching sealed provider request plan"
704            );
705
706            let mut rx = match self
707                .provider
708                .stream_with_tools(&plan.messages, &plan.tool_definitions)
709                .await
710            {
711                Ok(rx) => rx,
712                Err(error) => {
713                    if let Some(ref tx) = event_tx {
714                        let _ = tx.send(AgentEvent::Error {
715                            message: error.to_string(),
716                        });
717                    }
718                    let _ = self
719                        .run_hook(&hook_ctx, HookEvent::OnProviderError { error: &error })
720                        .await;
721                    break (
722                        Err(AgentError::ProviderError(error)),
723                        TurnStatus::ProviderError,
724                    );
725                }
726            };
727
728            let mut turn_tool_calls: Vec<PendingToolCall> = Vec::new();
729            let mut turn_text = String::new();
730            let mut turn_reasoning_blocks: Option<Vec<ReasoningBlock>> = None;
731            let mut saw_turn_end = false;
732            let mut turn_stop_reason: Option<StopReason> = None;
733            let mut usage = talos_core::message::Usage::default();
734
735            while let Some(event) = rx.recv().await {
736                if let Some(ref tx) = event_tx
737                    && !matches!(event, AgentEvent::ToolCall { .. })
738                {
739                    let _ = tx.send(event.clone());
740                }
741
742                match event {
743                    AgentEvent::TextDelta { delta } => {
744                        match self
745                            .run_hook(&hook_ctx, HookEvent::OnTextDelta { text: &delta })
746                            .await
747                        {
748                            Ok(HookOutcome::Continue(HookEvent::OnTextDelta { text }))
749                            | Ok(HookOutcome::Skip(HookEvent::OnTextDelta { text })) => {
750                                turn_text.push_str(text);
751                            }
752                            Ok(_) => turn_text.push_str(&delta),
753                            Err(error) => {
754                                break 'turn_loop (Err(error), TurnStatus::Denied);
755                            }
756                        }
757                    }
758                    AgentEvent::ToolCall {
759                        call, provenance, ..
760                    } => {
761                        let projected_call = self.project_tool_call(&call);
762                        match self
763                            .run_hook(
764                                &hook_ctx,
765                                HookEvent::OnToolCallProposed {
766                                    call: &projected_call,
767                                },
768                            )
769                            .await
770                        {
771                            Ok(HookOutcome::Continue(HookEvent::OnToolCallProposed {
772                                call: observed_call,
773                            }))
774                            | Ok(HookOutcome::Skip(HookEvent::OnToolCallProposed {
775                                call: observed_call,
776                            })) => {
777                                turn_tool_calls.push(PendingToolCall {
778                                    call: Self::restore_private_call_if_unchanged(
779                                        &call,
780                                        &projected_call,
781                                        observed_call,
782                                    ),
783                                    provenance,
784                                });
785                            }
786                            Ok(_) => turn_tool_calls.push(PendingToolCall { call, provenance }),
787                            Err(error) => {
788                                break 'turn_loop (Err(error), TurnStatus::Denied);
789                            }
790                        }
791                    }
792                    AgentEvent::TurnEnd {
793                        stop_reason,
794                        usage: turn_usage,
795                    } => {
796                        saw_turn_end = true;
797                        turn_stop_reason = Some(stop_reason.clone());
798                        usage = turn_usage;
799                        if usage.cache_read_tokens > 0 || usage.cache_write_tokens > 0 {
800                            tracing::debug!(
801                                cache_read = usage.cache_read_tokens,
802                                cache_write = usage.cache_write_tokens,
803                                input_tokens = usage.input_tokens,
804                                "provider cache metadata"
805                            );
806                        }
807                        let reason = Self::turn_end_reason(stop_reason);
808                        if let Err(error) = self
809                            .run_hook(&hook_ctx, HookEvent::OnTurnEnd { reason })
810                            .await
811                        {
812                            break 'turn_loop (Err(error), TurnStatus::Denied);
813                        }
814                    }
815                    AgentEvent::Error { message } => {
816                        let provider_error = ProviderError::InvalidResponse(message.clone());
817                        let _ = self
818                            .run_hook(
819                                &hook_ctx,
820                                HookEvent::OnProviderError {
821                                    error: &provider_error,
822                                },
823                            )
824                            .await;
825                        break 'turn_loop (
826                            Err(AgentError::UnexpectedEvent(message)),
827                            TurnStatus::UnexpectedEvent,
828                        );
829                    }
830                    AgentEvent::ReasoningComplete { blocks } => {
831                        turn_reasoning_blocks = Some(blocks);
832                    }
833                    AgentEvent::TurnStart | AgentEvent::ToolResult { .. } => {}
834                    _ => {}
835                }
836            }
837
838            let _ = self
839                .run_hook(
840                    &hook_ctx,
841                    HookEvent::AfterProviderCall {
842                        tokens_in: usage.input_tokens,
843                        tokens_out: usage.output_tokens,
844                    },
845                )
846                .await;
847
848            if !saw_turn_end {
849                break 'turn_loop (
850                    Err(AgentError::UnexpectedEvent(
851                        "channel closed before TurnEnd".into(),
852                    )),
853                    TurnStatus::UnexpectedEvent,
854                );
855            }
856
857            if matches!(turn_stop_reason, Some(StopReason::ToolUse)) && turn_tool_calls.is_empty() {
858                break 'turn_loop (
859                    Err(AgentError::UnexpectedEvent(
860                        "provider ended with tool_use but emitted no tool calls".into(),
861                    )),
862                    TurnStatus::UnexpectedEvent,
863                );
864            }
865
866            if !turn_tool_calls.is_empty() {
867                let mut seen_ids: HashSet<&str> = HashSet::new();
868                let duplicate_id = turn_tool_calls
869                    .iter()
870                    .find(|pending| !seen_ids.insert(pending.call.id.as_str()))
871                    .map(|pending| pending.call.id.clone());
872                if let Some(id) = duplicate_id {
873                    break 'turn_loop (
874                        Err(AgentError::UnexpectedEvent(format!(
875                            "provider emitted duplicate tool call id: {id}"
876                        ))),
877                        TurnStatus::UnexpectedEvent,
878                    );
879                }
880
881                // Defensive invariant: every emitted ToolCall must carry a
882                // non-empty id and name. The OpenAI-compatible SSE parser
883                // already synthesizes ids and skips empty names, but other
884                // providers (Anthropic, MCP bridging, future runtimes) must
885                // not be able to silently push a degenerate ToolCall that
886                // would later fail tool lookup or produce ambiguous
887                // request/response pairing on the next provider turn.
888                let degenerate = turn_tool_calls.iter().find(|pending| {
889                    pending.call.id.trim().is_empty() || pending.call.name.trim().is_empty()
890                });
891                if let Some(pending) = degenerate {
892                    break 'turn_loop (
893                        Err(AgentError::UnexpectedEvent(format!(
894                            "provider emitted tool call with empty id or name (id={:?}, name={:?})",
895                            pending.call.id, pending.call.name
896                        ))),
897                        TurnStatus::UnexpectedEvent,
898                    );
899                }
900            }
901
902            if turn_tool_calls.is_empty() {
903                let reasoning = turn_reasoning_blocks
904                    .take()
905                    .map(|blocks| AssistantReasoning {
906                        provider: self.provider_key.clone().unwrap_or_default(),
907                        model: self.model_id.clone().unwrap_or_default(),
908                        blocks,
909                    });
910                messages.push(Message::Assistant {
911                    content: talos_core::message::strip_tool_syntax(&turn_text),
912                    tool_calls: vec![],
913                    reasoning,
914                });
915                break (Ok(turn_text), TurnStatus::Success);
916            }
917
918            let proposed_tool_calls: Vec<ToolCall> = turn_tool_calls
919                .iter()
920                .map(|pending| pending.call.clone())
921                .collect();
922            let projected_tool_calls = proposed_tool_calls
923                .iter()
924                .map(|call| self.project_tool_call(call))
925                .collect::<Vec<_>>();
926
927            let effective_tool_calls = match self
928                .run_hook(
929                    &hook_ctx,
930                    HookEvent::BeforeToolBatch {
931                        calls: &projected_tool_calls,
932                    },
933                )
934                .await
935            {
936                Ok(HookOutcome::Continue(HookEvent::BeforeToolBatch { calls })) => {
937                    if calls == projected_tool_calls.as_slice() {
938                        proposed_tool_calls
939                    } else {
940                        calls.to_vec()
941                    }
942                }
943                Ok(HookOutcome::Skip(_)) => Vec::new(),
944                Ok(_) => proposed_tool_calls,
945                Err(error) => {
946                    break 'turn_loop (Err(error), TurnStatus::Denied);
947                }
948            };
949
950            total_tool_calls += effective_tool_calls.len();
951            if total_tool_calls > MAX_TOOL_CALLS_PER_TURN {
952                let _ = self
953                    .run_hook(
954                        &hook_ctx,
955                        HookEvent::OnBudgetExceeded {
956                            kind: BudgetKind::ToolCalls,
957                            used: total_tool_calls as u64,
958                            limit: MAX_TOOL_CALLS_PER_TURN as u64,
959                        },
960                    )
961                    .await;
962                break 'turn_loop (
963                    Ok(format!(
964                        "Reached the per-turn tool call limit ({MAX_TOOL_CALLS_PER_TURN}). \
965                             All results so far are preserved above — reply \"continue\" to resume."
966                    )),
967                    TurnStatus::BudgetExceeded,
968                );
969            }
970
971            for call in &effective_tool_calls {
972                let key = (call.name.clone(), call.input.to_string());
973                let count = doom_tracker.entry(key).or_insert(0);
974                *count += 1;
975                if *count >= DOOM_LOOP_THRESHOLD {
976                    let signature = format!(
977                        "tool '{}' called {} times with identical arguments",
978                        call.name, DOOM_LOOP_THRESHOLD
979                    );
980                    let _ = self
981                        .run_hook(
982                            &hook_ctx,
983                            HookEvent::OnDoomLoopDetected {
984                                signature: &signature,
985                            },
986                        )
987                        .await;
988                    break 'turn_loop (
989                        Ok(format!(
990                            "Detected a repeated call pattern ({signature}). Paused for \
991                                 review — all results are preserved above. Adjust your approach \
992                                 and reply \"continue\" to resume."
993                        )),
994                        TurnStatus::DoomLoopDetected,
995                    );
996                }
997            }
998
999            let cleaned_turn_text = talos_core::message::strip_tool_syntax(&turn_text);
1000            let reasoning = turn_reasoning_blocks
1001                .take()
1002                .map(|blocks| AssistantReasoning {
1003                    provider: self.provider_key.clone().unwrap_or_default(),
1004                    model: self.model_id.clone().unwrap_or_default(),
1005                    blocks,
1006                });
1007            let assistant_msg = Message::Assistant {
1008                content: cleaned_turn_text,
1009                tool_calls: effective_tool_calls.clone(),
1010                reasoning,
1011            };
1012            messages.push(assistant_msg);
1013
1014            let tool_results = if let Some(ref tx) = event_tx {
1015                let effective_pending =
1016                    self.pending_calls_with_provenance(&effective_tool_calls, &turn_tool_calls);
1017                match self
1018                    .execute_tools_for_ui_with_presentation(
1019                        &hook_ctx,
1020                        &effective_pending,
1021                        tx,
1022                        &mut messages,
1023                        &active_tool_presentation_policy,
1024                        &active_presented_tool_names,
1025                    )
1026                    .await
1027                {
1028                    Ok((results, parts)) => {
1029                        pending_continuation_parts.extend(parts);
1030                        results
1031                    }
1032                    Err(error) => {
1033                        break 'turn_loop (Err(error), TurnStatus::Denied);
1034                    }
1035                }
1036            } else {
1037                let (tool_results, parts) = match self
1038                    .execute_tools_with_presentation(
1039                        &hook_ctx,
1040                        &effective_tool_calls,
1041                        &active_tool_presentation_policy,
1042                        &active_presented_tool_names,
1043                    )
1044                    .await
1045                {
1046                    Ok((results, parts)) => (results, parts),
1047                    Err(error) => {
1048                        break 'turn_loop (Err(error), TurnStatus::Denied);
1049                    }
1050                };
1051                pending_continuation_parts.extend(parts);
1052
1053                for (call, result) in effective_tool_calls.iter().zip(tool_results.iter()) {
1054                    let projected_call = self.project_tool_call(call);
1055                    let projected_result = self.project_tool_result(&call.name, result);
1056                    let observation = ToolObservation {
1057                        call: projected_call.clone(),
1058                        result: projected_result.clone(),
1059                    };
1060                    let observed = match self
1061                        .run_hook(
1062                            &hook_ctx,
1063                            HookEvent::OnToolResultObserved {
1064                                observation: &observation,
1065                            },
1066                        )
1067                        .await
1068                    {
1069                        Ok(HookOutcome::Continue(HookEvent::OnToolResultObserved {
1070                            observation,
1071                        }))
1072                        | Ok(HookOutcome::Skip(HookEvent::OnToolResultObserved { observation })) => {
1073                            observation.clone()
1074                        }
1075                        Ok(_) => observation,
1076                        Err(error) => {
1077                            break 'turn_loop (Err(error), TurnStatus::Denied);
1078                        }
1079                    };
1080                    let observed = ToolObservation {
1081                        call: Self::restore_private_call_if_unchanged(
1082                            call,
1083                            &projected_call,
1084                            &observed.call,
1085                        ),
1086                        result: Self::restore_private_result_if_unchanged(
1087                            result,
1088                            &projected_result,
1089                            &observed.result,
1090                        ),
1091                    };
1092
1093                    let projection = self
1094                        .tools
1095                        .get(&observed.call.name)
1096                        .map(|tool| tool.project_result(&observed.result))
1097                        .unwrap_or_else(|| {
1098                            talos_core::tool::ToolResultProjection::shared(
1099                                observed.result.content.clone(),
1100                            )
1101                        });
1102                    let ui_result = MessageToolResult {
1103                        tool_use_id: observed.call.id.clone(),
1104                        content: projection.display_content,
1105                        is_error: observed.result.is_error,
1106                    };
1107                    let llm_result = if observed.result.is_error {
1108                        MessageToolResult {
1109                            content: format!(
1110                                "{}\n\n[Analyze the error above and try a different approach.]",
1111                                projection.model_content
1112                            ),
1113                            ..ui_result.clone()
1114                        }
1115                    } else if self.bash_compression_enabled
1116                        && should_compress_shell_output(&observed.call.name)
1117                    {
1118                        let compressed =
1119                            BashOutputCompressor::new().compress(&projection.model_content);
1120                        MessageToolResult {
1121                            content: compressed.content,
1122                            ..ui_result.clone()
1123                        }
1124                    } else if projection.model_content.len() > self.tool_output_threshold {
1125                        let compressed = crate::tool_output::compress_tool_output(
1126                            &projection.model_content,
1127                            self.tool_output_threshold,
1128                        );
1129                        MessageToolResult {
1130                            content: compressed.model_content,
1131                            ..ui_result.clone()
1132                        }
1133                    } else {
1134                        MessageToolResult {
1135                            content: projection.model_content,
1136                            ..ui_result.clone()
1137                        }
1138                    };
1139                    messages.push(Message::Tool { result: llm_result });
1140                }
1141
1142                tool_results
1143            };
1144
1145            self.apply_tool_continuations(
1146                &tool_results,
1147                &mut active_tool_presentation_policy,
1148                &mut active_tool_definitions,
1149                &mut active_presented_tool_names,
1150            );
1151
1152            let projected_batch = effective_tool_calls
1153                .iter()
1154                .zip(tool_results.iter())
1155                .map(|(call, result)| self.project_tool_result(&call.name, result))
1156                .collect::<Vec<_>>();
1157            let _ = self
1158                .run_hook(
1159                    &hook_ctx,
1160                    HookEvent::AfterToolBatch {
1161                        results: &projected_batch,
1162                    },
1163                )
1164                .await;
1165            if let Some(snapshot_tx) = &snapshot_tx {
1166                // This is the first safe boundary after a complete tool batch:
1167                // the projection excludes private fields and incomplete calls.
1168                let _ = snapshot_tx.send(self.persistence_projection(&messages[persist_start..]));
1169            }
1170        };
1171
1172        self.emit_turn_complete(&hook_ctx, final_status).await;
1173
1174        // Always extract the normalized partial messages from persist_start.
1175        // On success, these are the complete turn messages. On error, they may
1176        // contain valid completed tool exchanges that the session layer should
1177        // persist. Incomplete streamed assistant fragments are never pushed to
1178        // `messages` — only finalized assistant messages with complete tool
1179        // calls are (SESSION-006 / I135).
1180        let partial_messages = self.persistence_projection(&messages[persist_start..]);
1181        (result, partial_messages)
1182    }
1183
1184    fn persistence_projection(&self, messages: &[Message]) -> Vec<Message> {
1185        let mut tool_names = HashMap::<String, String>::new();
1186        messages
1187            .iter()
1188            .map(|message| match message {
1189                Message::Assistant {
1190                    content,
1191                    tool_calls,
1192                    reasoning,
1193                } => Message::Assistant {
1194                    content: content.clone(),
1195                    tool_calls: tool_calls
1196                        .iter()
1197                        .map(|call| {
1198                            tool_names.insert(call.id.clone(), call.name.clone());
1199                            let mut projected = call.clone();
1200                            if let Some(tool) = self.tools.get(&call.name) {
1201                                projected.input = tool.project_input(&call.input);
1202                            }
1203                            projected
1204                        })
1205                        .collect(),
1206                    reasoning: reasoning.clone(),
1207                },
1208                Message::Tool { result } => {
1209                    let content = tool_names
1210                        .get(&result.tool_use_id)
1211                        .and_then(|name| self.tools.get(name))
1212                        .map(|tool| {
1213                            let execution = talos_core::tool::ToolResult {
1214                                content: result.content.clone(),
1215                                is_error: result.is_error,
1216                                continuations: Vec::new(),
1217                            };
1218                            tool.project_result(&execution).persistence_content
1219                        })
1220                        .unwrap_or_else(|| result.content.clone());
1221                    Message::Tool {
1222                        result: MessageToolResult {
1223                            tool_use_id: result.tool_use_id.clone(),
1224                            content,
1225                            is_error: result.is_error,
1226                        },
1227                    }
1228                }
1229                _ => message.clone(),
1230            })
1231            .collect()
1232    }
1233
1234    fn apply_tool_continuations(
1235        &self,
1236        results: &[talos_core::tool::ToolResult],
1237        policy: &mut ToolPresentationPolicy,
1238        tool_definitions: &mut Vec<talos_core::provider::ToolDefinition>,
1239        presented_tool_names: &mut HashSet<String>,
1240    ) {
1241        let mut changed = false;
1242        for continuation in results
1243            .iter()
1244            .flat_map(|result| result.continuations.iter())
1245        {
1246            if continuation.is_tool_disclosure() {
1247                if !policy.tools.iter().any(|tool| tool == &continuation.tool) {
1248                    policy.tools.push(continuation.tool.clone());
1249                    changed = true;
1250                }
1251            } else {
1252                let backend = &continuation.backend;
1253                if !policy.allows_backend(&continuation.tool, backend) {
1254                    policy
1255                        .backends
1256                        .push(talos_core::tool::ToolBackendDisclosure::new(
1257                            continuation.tool.clone(),
1258                            backend.clone(),
1259                        ));
1260                    changed = true;
1261                }
1262            }
1263        }
1264
1265        if changed {
1266            let (_, definitions, names) = describe_presented_tools(&self.tools, policy);
1267            *tool_definitions = definitions;
1268            *presented_tool_names = names;
1269        }
1270    }
1271}
1272
1273#[allow(warnings)]
1274#[cfg(test)]
1275mod tests;
1276
1277#[cfg(test)]
1278mod i169_shell_compression_regression {
1279    use super::should_compress_shell_output;
1280
1281    #[test]
1282    fn production_shell_compression_predicate_covers_bash_and_powershell_only() {
1283        assert!(should_compress_shell_output("bash"));
1284        assert!(should_compress_shell_output("powershell"));
1285        assert!(!should_compress_shell_output("read"));
1286        assert!(!should_compress_shell_output("fetch_url"));
1287    }
1288}