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