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