1mod 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
110const MAX_TOOL_CALLS_PER_TURN: usize = 50;
112
113const MAX_CONCURRENT_READ_ONLY: usize = 10;
115
116const DOOM_LOOP_THRESHOLD: u32 = 3;
119
120fn should_compress_shell_output(tool_name: &str) -> bool {
121 matches!(tool_name, "bash" | "powershell")
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub struct RequestBudgetSpec {
127 pub requested_output_tokens: u32,
129 pub input_safety_margin_bps: u16,
131 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#[derive(Debug, Error)]
160pub enum AgentError {
161 #[error("provider error: {0}")]
163 ProviderError(#[from] ProviderError),
164
165 #[error("turn cancelled")]
167 Cancelled,
168
169 #[error("unexpected event: {0}")]
171 UnexpectedEvent(String),
172
173 #[error("tool error: {0}")]
175 ToolError(String),
176
177 #[error("turn budget exceeded: maximum of {MAX_TOOL_CALLS_PER_TURN} tool calls per turn")]
179 TurnBudgetExceeded,
180
181 #[error("request context budget exceeded: estimated {estimated} tokens, limit {limit}")]
183 ContextBudgetExceeded {
184 estimated: u32,
186 limit: u32,
188 },
189
190 #[error("doom loop detected: {0}")]
193 DoomLoopDetected(String),
194
195 #[error("hook denied operation: {0}")]
197 HookDenied(String),
198}
199
200pub type AgentResult<T> = Result<T, AgentError>;
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
205pub enum SandboxFallbackPolicy {
206 #[default]
208 Deny,
209 Ask,
211 AllowUnsandboxed,
213}
214
215#[derive(Debug, Clone, PartialEq)]
217pub struct SandboxFallbackContext {
218 pub tool_name: String,
220 pub arguments: serde_json::Value,
222 pub summary_fields: Vec<String>,
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub enum SandboxFallbackDecision {
230 ApproveOnce,
232 Deny,
234}
235
236#[async_trait::async_trait]
239pub trait SandboxFallbackHandler: Send + Sync {
240 async fn request_fallback(&self, context: SandboxFallbackContext) -> SandboxFallbackDecision;
242}
243
244type MemoryProviderCallback = dyn Fn(&str) -> Option<String> + Send + Sync;
246type TodoSectionProviderCallback = dyn Fn() -> Option<String> + Send + Sync;
248
249pub struct Agent {
285 provider: Arc<dyn LanguageModel>,
286 tool_protocol: talos_core::tool::ToolProtocol,
287 tools: ToolRegistry,
288 permission_pipeline: Option<Arc<permission_pipeline::PermissionPipeline>>,
290 permission_deadline: std::time::Duration,
292 sandbox: Option<Arc<dyn SandboxProvider>>,
294 sandbox_fallback_policy: SandboxFallbackPolicy,
296 sandbox_fallback_handler: Option<Arc<dyn SandboxFallbackHandler>>,
298 workspace_root: PathBuf,
300 prompt_builder: SystemPromptBuilder,
302 hook_registry: Arc<HookRegistry>,
304 workspace_context: Option<String>,
306 tool_definitions: Vec<talos_core::provider::ToolDefinition>,
308 presented_tool_names: HashSet<String>,
310 enforce_tool_presentation_policy: bool,
312 tool_presentation_policy: ToolPresentationPolicy,
314 cached_stable_prefix: std::sync::Mutex<Option<String>>,
317 memory_provider: Option<Arc<MemoryProviderCallback>>,
319 todo_section_provider: Option<Arc<TodoSectionProviderCallback>>,
321 provider_key: Option<String>,
323 model_id: Option<String>,
325 replay_reasoning: bool,
327 bash_compression_enabled: bool,
330 tool_output_threshold: usize,
331 image_input_supported: bool,
335 request_budget_spec: RequestBudgetSpec,
337 background_jobs: Option<Arc<dyn talos_core::background_job::BackgroundJobHost>>,
338 protocol_capability_cache: talos_core::tool::ProtocolCapabilityCache,
340 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 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 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 #[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 #[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 #[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 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 #[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 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 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 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 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 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 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 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 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}