1mod background_jobs;
42pub mod compaction;
43pub mod compression;
44mod process_tool;
45pub mod token;
46mod tool_output;
47
48use std::collections::HashMap;
49use std::collections::HashSet;
50use std::path::PathBuf;
51use std::sync::Arc;
52
53pub mod auto_resolver;
54pub mod caching;
55mod configuration;
56pub mod context;
57pub mod evaluator;
58mod helpers;
59pub mod permission_pipeline;
60pub mod prompt;
61mod request_plan;
62mod scheduler;
63pub mod session;
64mod tool_execution;
65
66pub use scheduler::{
67 PendingSchedulerActor, create_delay_tool_and_scheduler, create_scheduler_tools,
68};
69
70use talos_core::message::{
71 AgentEvent, AssistantReasoning, Message, MessageToolResult, ReasoningBlock, StopReason,
72 ToolCall,
73};
74use talos_core::provider::{LanguageModel, ProviderError};
75use talos_core::tool::{ToolPresentationPolicy, ToolProvenance, ToolRegistry};
76use talos_plugin::{
77 BudgetKind, HookContext, HookEvent, HookOutcome, HookRegistry, ToolObservation, TurnId,
78 TurnStatus,
79};
80use talos_sandbox::SandboxProvider;
81use thiserror::Error;
82use tokio::sync::mpsc;
83
84use crate::compression::BashOutputCompressor;
85use crate::configuration::describe_presented_tools;
86
87pub use compression::{CompressionMetrics, RetrievalMetrics};
88pub use prompt::{ActivatedSkillContext, ContextFile, SystemPromptBuilder, ToolDescription};
89pub(crate) use request_plan::PreparedSessionTurn;
90
91const MAX_TOOL_CALLS_PER_TURN: usize = 50;
93
94const MAX_CONCURRENT_READ_ONLY: usize = 10;
96
97const DOOM_LOOP_THRESHOLD: u32 = 3;
100
101fn should_compress_shell_output(tool_name: &str) -> bool {
102 matches!(tool_name, "bash" | "powershell")
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub struct RequestBudgetSpec {
108 pub requested_output_tokens: u32,
110 pub input_safety_margin_bps: u16,
112 pub fixed_overhead_tokens: u32,
114}
115
116impl RequestBudgetSpec {
117 #[must_use]
118 pub const fn new(requested_output_tokens: u32) -> Self {
119 Self {
120 requested_output_tokens,
121 input_safety_margin_bps: 2_500,
122 fixed_overhead_tokens: 256,
123 }
124 }
125}
126
127impl Default for RequestBudgetSpec {
128 fn default() -> Self {
129 Self::new(4096)
130 }
131}
132
133#[derive(Debug, Clone)]
134struct PendingToolCall {
135 call: ToolCall,
136 provenance: ToolProvenance,
137}
138
139#[derive(Debug, Error)]
141pub enum AgentError {
142 #[error("provider error: {0}")]
144 ProviderError(#[from] ProviderError),
145
146 #[error("turn cancelled")]
148 Cancelled,
149
150 #[error("unexpected event: {0}")]
152 UnexpectedEvent(String),
153
154 #[error("tool error: {0}")]
156 ToolError(String),
157
158 #[error("turn budget exceeded: maximum of {MAX_TOOL_CALLS_PER_TURN} tool calls per turn")]
160 TurnBudgetExceeded,
161
162 #[error("request context budget exceeded: estimated {estimated} tokens, limit {limit}")]
164 ContextBudgetExceeded {
165 estimated: u32,
167 limit: u32,
169 },
170
171 #[error("doom loop detected: {0}")]
174 DoomLoopDetected(String),
175
176 #[error("hook denied operation: {0}")]
178 HookDenied(String),
179}
180
181pub type AgentResult<T> = Result<T, AgentError>;
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
186pub enum SandboxFallbackPolicy {
187 #[default]
189 Deny,
190 Ask,
192 AllowUnsandboxed,
194}
195
196#[derive(Debug, Clone, PartialEq)]
198pub struct SandboxFallbackContext {
199 pub tool_name: String,
201 pub arguments: serde_json::Value,
203 pub summary_fields: Vec<String>,
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum SandboxFallbackDecision {
211 ApproveOnce,
213 Deny,
215}
216
217#[async_trait::async_trait]
220pub trait SandboxFallbackHandler: Send + Sync {
221 async fn request_fallback(&self, context: SandboxFallbackContext) -> SandboxFallbackDecision;
223}
224
225type MemoryProviderCallback = dyn Fn(&str) -> Option<String> + Send + Sync;
227type TodoSectionProviderCallback = dyn Fn() -> Option<String> + Send + Sync;
229
230pub struct Agent {
266 provider: Arc<dyn LanguageModel>,
267 tools: ToolRegistry,
268 permission_pipeline: Option<Arc<permission_pipeline::PermissionPipeline>>,
270 permission_deadline: std::time::Duration,
272 sandbox: Option<Arc<dyn SandboxProvider>>,
274 sandbox_fallback_policy: SandboxFallbackPolicy,
276 sandbox_fallback_handler: Option<Arc<dyn SandboxFallbackHandler>>,
278 workspace_root: PathBuf,
280 prompt_builder: SystemPromptBuilder,
282 hook_registry: Arc<HookRegistry>,
284 workspace_context: Option<String>,
286 tool_definitions: Vec<talos_core::provider::ToolDefinition>,
288 presented_tool_names: HashSet<String>,
290 enforce_tool_presentation_policy: bool,
292 tool_presentation_policy: ToolPresentationPolicy,
294 cached_stable_prefix: std::sync::Mutex<Option<String>>,
297 memory_provider: Option<Arc<MemoryProviderCallback>>,
299 todo_section_provider: Option<Arc<TodoSectionProviderCallback>>,
301 provider_key: Option<String>,
303 model_id: Option<String>,
305 replay_reasoning: bool,
307 bash_compression_enabled: bool,
310 tool_output_threshold: usize,
311 image_input_supported: bool,
315 request_budget_spec: RequestBudgetSpec,
317 background_jobs: Option<Arc<dyn talos_core::background_job::BackgroundJobHost>>,
318}
319impl Agent {
320 pub(crate) fn set_background_job_host(
321 &mut self,
322 host: Arc<dyn talos_core::background_job::BackgroundJobHost>,
323 ) {
324 self.background_jobs = Some(host);
325 }
326
327 pub(crate) fn register_process_tool(
328 &mut self,
329 supervisor: crate::background_jobs::BackgroundJobSupervisor,
330 ) {
331 self.tools
332 .register(Arc::new(crate::process_tool::ProcessTool::new(supervisor)));
333 let (descriptions, definitions, names) = crate::configuration::describe_presented_tools(
334 &self.tools,
335 &self.tool_presentation_policy,
336 );
337 self.tool_definitions = definitions;
338 self.presented_tool_names = names;
339 self.update_prompt_builder(true, |builder| builder.with_tools(descriptions));
340 }
341
342 pub fn provider(&self) -> &dyn LanguageModel {
343 self.provider.as_ref()
344 }
345
346 pub async fn run(&self, user_message: String) -> AgentResult<String> {
354 let (result, _) = self.run_inner(user_message, vec![], None, None).await;
355 result
356 }
357
358 pub async fn run_streaming(
375 &self,
376 user_message: String,
377 history: Vec<Message>,
378 event_tx: mpsc::UnboundedSender<AgentEvent>,
379 ) -> AgentResult<(String, Vec<Message>)> {
380 let (result, messages) = self
381 .run_inner(user_message, history, Some(event_tx), None)
382 .await;
383 result.map(|text| (text, messages))
384 }
385
386 #[allow(dead_code)]
396 pub(crate) async fn run_for_session_turn(
397 &self,
398 user_message: String,
399 history: Vec<Message>,
400 event_tx: mpsc::UnboundedSender<AgentEvent>,
401 ) -> (AgentResult<String>, Vec<Message>) {
402 self.run_inner(user_message, history, Some(event_tx), None)
403 .await
404 }
405
406 #[allow(dead_code)]
410 pub(crate) async fn run_for_session_turn_multimodal(
411 &self,
412 user_message: String,
413 attachments: Vec<talos_core::message::ContentPart>,
414 history: Vec<Message>,
415 event_tx: mpsc::UnboundedSender<AgentEvent>,
416 ) -> (AgentResult<String>, Vec<Message>) {
417 self.run_inner(
418 user_message,
419 history,
420 Some(event_tx),
421 if attachments.is_empty() {
422 None
423 } else {
424 Some(attachments)
425 },
426 )
427 .await
428 }
429
430 #[allow(dead_code)]
440 pub(crate) async fn estimate_session_request_tokens(
441 &self,
442 items: &[talos_core::session::SubmissionItem],
443 history: Vec<Message>,
444 ) -> AgentResult<u32> {
445 let memory_query = items
446 .iter()
447 .map(|item| item.text.as_str())
448 .collect::<Vec<_>>()
449 .join("\n");
450 let hook_ctx = HookContext::new(TurnId::new(), self.workspace_root.clone());
451 let (mut messages, _) = self
452 .build_provider_messages(memory_query, history, &hook_ctx)
453 .await?;
454 messages.pop();
455 messages.extend(items.iter().map(|item| {
456 if item.attachments.is_empty() {
457 Message::User {
458 content: item.text.clone(),
459 }
460 } else {
461 let mut parts = Vec::with_capacity(item.attachments.len() + 1);
462 if !item.text.is_empty() {
463 parts.push(talos_core::message::ContentPart::Text {
464 text: item.text.clone(),
465 });
466 }
467 parts.extend(item.attachments.clone());
468 Message::Multimodal { parts }
469 }
470 }));
471
472 let (_, mut tool_definitions, _) =
473 describe_presented_tools(&self.tools, &self.tool_presentation_policy);
474 if !self.image_input_supported {
475 tool_definitions.retain(|definition| definition.name != "read_image");
476 }
477 Ok(self.estimate_provider_request_tokens(&messages, &tool_definitions))
478 }
479
480 fn estimate_provider_request_tokens(
481 &self,
482 messages: &[Message],
483 tool_definitions: &[talos_core::provider::ToolDefinition],
484 ) -> u32 {
485 let tool_tokens = tool_definitions.iter().fold(0_u32, |total, definition| {
486 total
487 .saturating_add(crate::token::TokenEstimator::estimate_text(
488 &definition.name,
489 ))
490 .saturating_add(crate::token::TokenEstimator::estimate_text(
491 &definition.description,
492 ))
493 .saturating_add(crate::token::TokenEstimator::estimate_text(
494 &definition.parameters.to_string(),
495 ))
496 });
497 let raw_input = crate::token::TokenEstimator::new()
498 .estimate(messages)
499 .saturating_add(tool_tokens);
500 let proportional_margin = u64::from(raw_input)
501 .saturating_mul(u64::from(self.request_budget_spec.input_safety_margin_bps))
502 .div_ceil(10_000);
503 raw_input
504 .saturating_add(u32::try_from(proportional_margin).unwrap_or(u32::MAX))
505 .saturating_add(self.request_budget_spec.fixed_overhead_tokens)
506 .saturating_add(self.request_budget_spec.requested_output_tokens)
507 }
508
509 pub async fn preview_request(
515 &self,
516 user_message: String,
517 history: Vec<Message>,
518 ) -> AgentResult<Option<String>> {
519 let turn_id = TurnId::new();
520 let hook_ctx = HookContext::new(turn_id, self.workspace_root.clone());
521 let (messages, _) = self
522 .build_provider_messages(user_message, history, &hook_ctx)
523 .await?;
524
525 Ok(self.provider.request_preview(&messages).map(|preview| {
526 let snapshot =
527 serde_json::to_string_pretty(&preview).unwrap_or_else(|_| preview.to_string());
528 format!("Request preview (no API call made):\n\n```json\n{snapshot}\n```")
529 }))
530 }
531
532 async fn build_provider_messages(
533 &self,
534 user_message: String,
535 history: Vec<Message>,
536 hook_ctx: &HookContext,
537 ) -> AgentResult<(Vec<Message>, usize)> {
538 let mut prompt_builder = if let Some(ref mem_provider) = self.memory_provider {
539 let memory_section = mem_provider(&user_message);
540 self.prompt_builder
541 .clone()
542 .with_memory_section(memory_section)
543 } else {
544 self.prompt_builder.clone()
545 };
546 if let Some(ref todo_provider) = self.todo_section_provider {
547 prompt_builder = prompt_builder.with_todo_section(todo_provider());
548 }
549
550 let stable_prefix = {
551 let mut cache = self
552 .cached_stable_prefix
553 .lock()
554 .expect("cache lock poisoned");
555 match cache.as_ref() {
556 Some(cached) => cached.clone(),
557 None => {
558 let prefix = prompt_builder.build_stable_prefix();
559 *cache = Some(prefix.clone());
560 prefix
561 }
562 }
563 };
564 let stable_prefix_len = stable_prefix.len();
565 let dynamic_suffix = prompt_builder.build_dynamic_suffix();
566 let combined = if stable_prefix.is_empty() {
567 dynamic_suffix
568 } else if dynamic_suffix.is_empty() {
569 stable_prefix
570 } else {
571 format!("{stable_prefix}\n{dynamic_suffix}")
572 };
573
574 let (system_prompt, cache_markers) = prompt_builder
575 .build_with_hooks_from_prompt(
576 self.hook_registry.as_ref(),
577 hook_ctx,
578 &combined,
579 stable_prefix_len,
580 )
581 .await
582 .map_err(AgentError::HookDenied)?;
583
584 let mut messages = history;
585
586 if !system_prompt.is_empty() {
587 messages.push(Message::System {
588 content: system_prompt,
589 cache_markers,
590 });
591 }
592
593 if let Some(ref context) = self.workspace_context
594 && !context.is_empty()
595 {
596 messages.push(Message::Context {
597 content: context.clone(),
598 });
599 }
600
601 let persist_start = messages.len();
602
603 messages.push(Message::User {
604 content: user_message,
605 });
606
607 Ok((messages, persist_start))
608 }
609
610 #[allow(dead_code)]
613 async fn build_provider_messages_with_attachments(
614 &self,
615 user_message: String,
616 history: Vec<Message>,
617 hook_ctx: &HookContext,
618 attachments: Vec<talos_core::message::ContentPart>,
619 ) -> AgentResult<(Vec<Message>, usize)> {
620 let (mut messages, persist_start) = self
621 .build_provider_messages(user_message, history, hook_ctx)
622 .await?;
623
624 if let Some(Message::User { content: _ }) = messages.last() {
625 let mut parts = Vec::new();
626 if let Some(Message::User { content }) = messages.last_mut()
627 && !content.is_empty()
628 {
629 parts.push(talos_core::message::ContentPart::Text {
630 text: content.clone(),
631 });
632 }
633 parts.extend(attachments);
634 if let Some(last) = messages.last_mut() {
635 *last = Message::Multimodal { parts };
636 }
637 }
638
639 Ok((messages, persist_start))
640 }
641
642 async fn run_inner(
647 &self,
648 user_message: String,
649 history: Vec<Message>,
650 event_tx: Option<mpsc::UnboundedSender<AgentEvent>>,
651 attachments: Option<Vec<talos_core::message::ContentPart>>,
652 ) -> (AgentResult<String>, Vec<Message>) {
653 let input_messages = if let Some(atts) = attachments {
654 let mut parts = Vec::with_capacity(atts.len() + 1);
655 if !user_message.is_empty() {
656 parts.push(talos_core::message::ContentPart::Text {
657 text: user_message.clone(),
658 });
659 }
660 parts.extend(atts);
661 vec![Message::Multimodal { parts }]
662 } else {
663 vec![Message::User {
664 content: user_message.clone(),
665 }]
666 };
667 self.run_inner_with_messages(user_message, input_messages, history, event_tx, None)
668 .await
669 }
670
671 async fn run_inner_with_messages(
672 &self,
673 memory_query: String,
674 input_messages: Vec<Message>,
675 history: Vec<Message>,
676 event_tx: Option<mpsc::UnboundedSender<AgentEvent>>,
677 request_context_limit: Option<u32>,
678 ) -> (AgentResult<String>, Vec<Message>) {
679 let prepared = match self
680 .prepare_turn_start(memory_query, input_messages, history, request_context_limit)
681 .await
682 {
683 Ok(prepared) => prepared,
684 Err(error) => return (Err(error), Vec::new()),
685 };
686 self.run_prepared_inner(prepared, event_tx, None).await
687 }
688
689 async fn run_prepared_inner(
690 &self,
691 prepared: PreparedSessionTurn,
692 event_tx: Option<mpsc::UnboundedSender<AgentEvent>>,
693 snapshot_tx: Option<mpsc::UnboundedSender<Vec<Message>>>,
694 ) -> (AgentResult<String>, Vec<Message>) {
695 let PreparedSessionTurn {
696 hook_ctx,
697 mut messages,
698 persist_start,
699 mut active_tool_presentation_policy,
700 mut active_tool_definitions,
701 mut active_presented_tool_names,
702 initial_plan,
703 request_context_limit,
704 } = prepared;
705 let mut total_tool_calls: usize = 0;
706 let mut doom_tracker: HashMap<(String, String), u32> = HashMap::new();
707 let mut pending_continuation_parts: Vec<talos_core::message::ContentPart> = Vec::new();
708 let mut initial_plan = Some(initial_plan);
709
710 if let Some(snapshot_tx) = &snapshot_tx {
711 let _ = snapshot_tx.send(self.persistence_projection(&messages[persist_start..]));
712 }
713
714 let (result, final_status) = 'turn_loop: loop {
715 let plan = if let Some(plan) = initial_plan.take() {
716 plan
717 } else {
718 match self
719 .seal_provider_request_plan(
720 &hook_ctx,
721 &messages,
722 &active_tool_definitions,
723 &mut pending_continuation_parts,
724 request_context_limit,
725 )
726 .await
727 {
728 Ok(plan) => plan,
729 Err(error) => break (Err(error), TurnStatus::Denied),
730 }
731 };
732 tracing::trace!(
733 estimated_tokens = plan.estimated_tokens,
734 "dispatching sealed provider request plan"
735 );
736
737 let (progress_tx, mut progress_rx) = mpsc::unbounded_channel();
738 let provider_request = self.provider.stream_with_tools_and_progress(
739 &plan.messages,
740 &plan.tool_definitions,
741 progress_tx,
742 );
743 tokio::pin!(provider_request);
744 let provider_result = loop {
745 tokio::select! {
746 biased;
747 progress = progress_rx.recv() => {
748 match progress {
749 Some(progress) => {
750 if let Some(ref tx) = event_tx {
751 let _ = tx.send(AgentEvent::ProviderProgress { progress });
752 }
753 }
754 None => break provider_request.await,
755 }
756 }
757 result = &mut provider_request => {
758 while let Ok(progress) = progress_rx.try_recv() {
759 if let Some(ref tx) = event_tx {
760 let _ = tx.send(AgentEvent::ProviderProgress { progress });
761 }
762 }
763 break result;
764 }
765 }
766 };
767
768 let mut rx = match provider_result {
769 Ok(rx) => rx,
770 Err(error) => {
771 if let Some(ref tx) = event_tx {
772 let _ = tx.send(AgentEvent::Error {
773 message: error.to_string(),
774 });
775 }
776 let _ = self
777 .run_hook(&hook_ctx, HookEvent::OnProviderError { error: &error })
778 .await;
779 break (
780 Err(AgentError::ProviderError(error)),
781 TurnStatus::ProviderError,
782 );
783 }
784 };
785
786 let mut turn_tool_calls: Vec<PendingToolCall> = Vec::new();
787 let mut turn_text = String::new();
788 let mut turn_reasoning_blocks: Option<Vec<ReasoningBlock>> = None;
789 let mut saw_turn_end = false;
790 let mut turn_stop_reason: Option<StopReason> = None;
791 let mut usage = talos_core::message::Usage::default();
792
793 while let Some(event) = rx.recv().await {
794 if let Some(ref tx) = event_tx
795 && !matches!(event, AgentEvent::ToolCall { .. })
796 {
797 let _ = tx.send(event.clone());
798 }
799
800 match event {
801 AgentEvent::TextDelta { delta } => {
802 match self
803 .run_hook(&hook_ctx, HookEvent::OnTextDelta { text: &delta })
804 .await
805 {
806 Ok(HookOutcome::Continue(HookEvent::OnTextDelta { text }))
807 | Ok(HookOutcome::Skip(HookEvent::OnTextDelta { text })) => {
808 turn_text.push_str(text);
809 }
810 Ok(_) => turn_text.push_str(&delta),
811 Err(error) => {
812 break 'turn_loop (Err(error), TurnStatus::Denied);
813 }
814 }
815 }
816 AgentEvent::ToolCall {
817 mut call,
818 provenance,
819 ..
820 } => {
821 call.input =
822 permission_pipeline::normalize_permission_input(&call.name, call.input);
823 turn_tool_calls.push(PendingToolCall { call, provenance });
824 }
825 AgentEvent::TurnEnd {
826 stop_reason,
827 usage: turn_usage,
828 } => {
829 saw_turn_end = true;
830 turn_stop_reason = Some(stop_reason.clone());
831 usage = turn_usage;
832 if usage.cache_read_tokens > 0 || usage.cache_write_tokens > 0 {
833 tracing::debug!(
834 cache_read = usage.cache_read_tokens,
835 cache_write = usage.cache_write_tokens,
836 input_tokens = usage.input_tokens,
837 "provider cache metadata"
838 );
839 }
840 let reason = Self::turn_end_reason(stop_reason);
841 if let Err(error) = self
842 .run_hook(&hook_ctx, HookEvent::OnTurnEnd { reason })
843 .await
844 {
845 break 'turn_loop (Err(error), TurnStatus::Denied);
846 }
847 }
848 AgentEvent::Error { message } => {
849 let provider_error = ProviderError::InvalidResponse(message.clone());
850 let _ = self
851 .run_hook(
852 &hook_ctx,
853 HookEvent::OnProviderError {
854 error: &provider_error,
855 },
856 )
857 .await;
858 break 'turn_loop (
859 Err(AgentError::UnexpectedEvent(message)),
860 TurnStatus::UnexpectedEvent,
861 );
862 }
863 AgentEvent::ReasoningComplete { blocks } => {
864 turn_reasoning_blocks = Some(blocks);
865 }
866 AgentEvent::TurnStart
867 | AgentEvent::ProviderProgress { .. }
868 | AgentEvent::ToolResult { .. } => {}
869 _ => {}
870 }
871 }
872
873 let _ = self
874 .run_hook(
875 &hook_ctx,
876 HookEvent::AfterProviderCall {
877 tokens_in: usage.input_tokens,
878 tokens_out: usage.output_tokens,
879 },
880 )
881 .await;
882
883 if !saw_turn_end {
884 break 'turn_loop (
885 Err(AgentError::UnexpectedEvent(
886 "channel closed before TurnEnd".into(),
887 )),
888 TurnStatus::UnexpectedEvent,
889 );
890 }
891
892 if matches!(turn_stop_reason, Some(StopReason::ToolUse)) && turn_tool_calls.is_empty() {
893 break 'turn_loop (
894 Err(AgentError::UnexpectedEvent(
895 "provider ended with tool_use but emitted no tool calls".into(),
896 )),
897 TurnStatus::UnexpectedEvent,
898 );
899 }
900
901 if !turn_tool_calls.is_empty() {
902 let mut seen_ids: HashSet<&str> = HashSet::new();
903 let duplicate_id = turn_tool_calls
904 .iter()
905 .find(|pending| !seen_ids.insert(pending.call.id.as_str()))
906 .map(|pending| pending.call.id.clone());
907 if let Some(id) = duplicate_id {
908 break 'turn_loop (
909 Err(AgentError::UnexpectedEvent(format!(
910 "provider emitted duplicate tool call id: {id}"
911 ))),
912 TurnStatus::UnexpectedEvent,
913 );
914 }
915
916 let degenerate = turn_tool_calls.iter().find(|pending| {
924 pending.call.id.trim().is_empty() || pending.call.name.trim().is_empty()
925 });
926 if let Some(pending) = degenerate {
927 break 'turn_loop (
928 Err(AgentError::UnexpectedEvent(format!(
929 "provider emitted tool call with empty id or name (id={:?}, name={:?})",
930 pending.call.id, pending.call.name
931 ))),
932 TurnStatus::UnexpectedEvent,
933 );
934 }
935 }
936
937 if turn_tool_calls.is_empty() {
938 let reasoning = turn_reasoning_blocks
939 .take()
940 .map(|blocks| AssistantReasoning {
941 provider: self.provider_key.clone().unwrap_or_default(),
942 model: self.model_id.clone().unwrap_or_default(),
943 blocks,
944 });
945 messages.push(Message::Assistant {
946 content: talos_core::message::strip_tool_syntax(&turn_text),
947 tool_calls: vec![],
948 reasoning,
949 });
950 break (Ok(turn_text), TurnStatus::Success);
951 }
952
953 let proposed_tool_calls: Vec<ToolCall> = turn_tool_calls
954 .iter()
955 .map(|pending| pending.call.clone())
956 .collect();
957 let projected_tool_calls = proposed_tool_calls
958 .iter()
959 .map(|call| self.project_tool_call(call))
960 .collect::<Vec<_>>();
961
962 let effective_tool_calls = match self
963 .run_hook(
964 &hook_ctx,
965 HookEvent::BeforeToolBatch {
966 calls: &projected_tool_calls,
967 },
968 )
969 .await
970 {
971 Ok(HookOutcome::Continue(HookEvent::BeforeToolBatch { calls })) => {
972 if calls == projected_tool_calls.as_slice() {
973 proposed_tool_calls
974 } else {
975 calls.to_vec()
976 }
977 }
978 Ok(HookOutcome::Skip(_)) => Vec::new(),
979 Ok(_) => proposed_tool_calls,
980 Err(error) => {
981 break 'turn_loop (Err(error), TurnStatus::Denied);
982 }
983 };
984
985 total_tool_calls += effective_tool_calls.len();
986 if total_tool_calls > MAX_TOOL_CALLS_PER_TURN {
987 let _ = self
988 .run_hook(
989 &hook_ctx,
990 HookEvent::OnBudgetExceeded {
991 kind: BudgetKind::ToolCalls,
992 used: total_tool_calls as u64,
993 limit: MAX_TOOL_CALLS_PER_TURN as u64,
994 },
995 )
996 .await;
997 break 'turn_loop (
998 Ok(format!(
999 "Reached the per-turn tool call limit ({MAX_TOOL_CALLS_PER_TURN}). \
1000 All results so far are preserved above — reply \"continue\" to resume."
1001 )),
1002 TurnStatus::BudgetExceeded,
1003 );
1004 }
1005
1006 for call in &effective_tool_calls {
1007 let key = (call.name.clone(), call.input.to_string());
1008 let count = doom_tracker.entry(key).or_insert(0);
1009 *count += 1;
1010 if *count >= DOOM_LOOP_THRESHOLD {
1011 let signature = format!(
1012 "tool '{}' called {} times with identical arguments",
1013 call.name, DOOM_LOOP_THRESHOLD
1014 );
1015 let _ = self
1016 .run_hook(
1017 &hook_ctx,
1018 HookEvent::OnDoomLoopDetected {
1019 signature: &signature,
1020 },
1021 )
1022 .await;
1023 break 'turn_loop (
1024 Ok(format!(
1025 "Detected a repeated call pattern ({signature}). Paused for \
1026 review — all results are preserved above. Adjust your approach \
1027 and reply \"continue\" to resume."
1028 )),
1029 TurnStatus::DoomLoopDetected,
1030 );
1031 }
1032 }
1033
1034 let cleaned_turn_text = talos_core::message::strip_tool_syntax(&turn_text);
1035 let reasoning = turn_reasoning_blocks
1036 .take()
1037 .map(|blocks| AssistantReasoning {
1038 provider: self.provider_key.clone().unwrap_or_default(),
1039 model: self.model_id.clone().unwrap_or_default(),
1040 blocks,
1041 });
1042 let assistant_msg = Message::Assistant {
1043 content: cleaned_turn_text,
1044 tool_calls: effective_tool_calls.clone(),
1045 reasoning,
1046 };
1047 messages.push(assistant_msg);
1048
1049 let tool_results = if let Some(ref tx) = event_tx {
1050 let effective_pending =
1051 self.pending_calls_with_provenance(&effective_tool_calls, &turn_tool_calls);
1052 let user_intent = messages
1053 .iter()
1054 .rev()
1055 .find_map(|message| match message {
1056 Message::User { content } => Some(content.as_str()),
1057 _ => None,
1058 })
1059 .map(str::to_owned);
1060 match self
1061 .execute_tools_for_ui_with_presentation(
1062 &hook_ctx,
1063 &effective_pending,
1064 tx,
1065 &mut messages,
1066 user_intent.as_deref(),
1067 &active_tool_presentation_policy,
1068 &active_presented_tool_names,
1069 )
1070 .await
1071 {
1072 Ok((results, parts)) => {
1073 pending_continuation_parts.extend(parts);
1074 results
1075 }
1076 Err(error) => {
1077 break 'turn_loop (Err(error), TurnStatus::Denied);
1078 }
1079 }
1080 } else {
1081 let user_intent = messages.iter().rev().find_map(|message| match message {
1082 Message::User { content } => Some(content.as_str()),
1083 _ => None,
1084 });
1085 let (tool_results, parts) = match self
1086 .execute_tools_with_presentation(
1087 &hook_ctx,
1088 &effective_tool_calls,
1089 user_intent,
1090 &active_tool_presentation_policy,
1091 &active_presented_tool_names,
1092 )
1093 .await
1094 {
1095 Ok((results, parts)) => (results, parts),
1096 Err(error) => {
1097 break 'turn_loop (Err(error), TurnStatus::Denied);
1098 }
1099 };
1100 pending_continuation_parts.extend(parts);
1101
1102 for (call, result) in effective_tool_calls.iter().zip(tool_results.iter()) {
1103 let projected_call = self.project_tool_call(call);
1104 let projected_result = self.project_tool_result(&call.name, result);
1105 let observation = ToolObservation {
1106 call: projected_call.clone(),
1107 result: projected_result.clone(),
1108 };
1109 let observed = match self
1110 .run_hook(
1111 &hook_ctx,
1112 HookEvent::OnToolResultObserved {
1113 observation: &observation,
1114 },
1115 )
1116 .await
1117 {
1118 Ok(HookOutcome::Continue(HookEvent::OnToolResultObserved {
1119 observation,
1120 }))
1121 | Ok(HookOutcome::Skip(HookEvent::OnToolResultObserved { observation })) => {
1122 observation.clone()
1123 }
1124 Ok(_) => observation,
1125 Err(error) => {
1126 break 'turn_loop (Err(error), TurnStatus::Denied);
1127 }
1128 };
1129 let observed = ToolObservation {
1130 call: Self::restore_private_call_if_unchanged(
1131 call,
1132 &projected_call,
1133 &observed.call,
1134 ),
1135 result: Self::restore_private_result_if_unchanged(
1136 result,
1137 &projected_result,
1138 &observed.result,
1139 ),
1140 };
1141
1142 let projection = self
1143 .tools
1144 .get(&observed.call.name)
1145 .map(|tool| tool.project_result(&observed.result))
1146 .unwrap_or_else(|| {
1147 talos_core::tool::ToolResultProjection::shared(
1148 observed.result.content.clone(),
1149 )
1150 });
1151 let ui_result = MessageToolResult {
1152 tool_use_id: observed.call.id.clone(),
1153 content: projection.display_content,
1154 is_error: observed.result.is_error,
1155 };
1156 let llm_result = if observed.result.is_error {
1157 MessageToolResult {
1158 content: format!(
1159 "{}\n\n[Analyze the error above and try a different approach.]",
1160 projection.model_content
1161 ),
1162 ..ui_result.clone()
1163 }
1164 } else if self.bash_compression_enabled
1165 && should_compress_shell_output(&observed.call.name)
1166 {
1167 let compressed =
1168 BashOutputCompressor::new().compress(&projection.model_content);
1169 MessageToolResult {
1170 content: compressed.content,
1171 ..ui_result.clone()
1172 }
1173 } else if projection.model_content.len() > self.tool_output_threshold {
1174 let compressed = crate::tool_output::compress_tool_output(
1175 &projection.model_content,
1176 self.tool_output_threshold,
1177 );
1178 MessageToolResult {
1179 content: compressed.model_content,
1180 ..ui_result.clone()
1181 }
1182 } else {
1183 MessageToolResult {
1184 content: projection.model_content,
1185 ..ui_result.clone()
1186 }
1187 };
1188 messages.push(Message::Tool { result: llm_result });
1189 }
1190
1191 tool_results
1192 };
1193
1194 self.apply_tool_continuations(
1195 &tool_results,
1196 &mut active_tool_presentation_policy,
1197 &mut active_tool_definitions,
1198 &mut active_presented_tool_names,
1199 );
1200
1201 let projected_batch = effective_tool_calls
1202 .iter()
1203 .zip(tool_results.iter())
1204 .map(|(call, result)| self.project_tool_result(&call.name, result))
1205 .collect::<Vec<_>>();
1206 let _ = self
1207 .run_hook(
1208 &hook_ctx,
1209 HookEvent::AfterToolBatch {
1210 results: &projected_batch,
1211 },
1212 )
1213 .await;
1214 if let Some(snapshot_tx) = &snapshot_tx {
1215 let _ = snapshot_tx.send(self.persistence_projection(&messages[persist_start..]));
1218 }
1219 };
1220
1221 self.emit_turn_complete(&hook_ctx, final_status).await;
1222
1223 let partial_messages = self.persistence_projection(&messages[persist_start..]);
1230 (result, partial_messages)
1231 }
1232
1233 fn persistence_projection(&self, messages: &[Message]) -> Vec<Message> {
1234 let mut tool_names = HashMap::<String, String>::new();
1235 messages
1236 .iter()
1237 .map(|message| match message {
1238 Message::Assistant {
1239 content,
1240 tool_calls,
1241 reasoning,
1242 } => Message::Assistant {
1243 content: content.clone(),
1244 tool_calls: tool_calls
1245 .iter()
1246 .map(|call| {
1247 tool_names.insert(call.id.clone(), call.name.clone());
1248 let mut projected = call.clone();
1249 if let Some(tool) = self.tools.get(&call.name) {
1250 projected.input = tool.project_input(&call.input);
1251 }
1252 projected
1253 })
1254 .collect(),
1255 reasoning: reasoning.clone(),
1256 },
1257 Message::Tool { result } => {
1258 let content = tool_names
1259 .get(&result.tool_use_id)
1260 .and_then(|name| self.tools.get(name))
1261 .map(|tool| {
1262 let execution = talos_core::tool::ToolResult {
1263 content: result.content.clone(),
1264 is_error: result.is_error,
1265 continuations: Vec::new(),
1266 };
1267 tool.project_result(&execution).persistence_content
1268 })
1269 .unwrap_or_else(|| result.content.clone());
1270 Message::Tool {
1271 result: MessageToolResult {
1272 tool_use_id: result.tool_use_id.clone(),
1273 content,
1274 is_error: result.is_error,
1275 },
1276 }
1277 }
1278 _ => message.clone(),
1279 })
1280 .collect()
1281 }
1282
1283 fn apply_tool_continuations(
1284 &self,
1285 results: &[talos_core::tool::ToolResult],
1286 policy: &mut ToolPresentationPolicy,
1287 tool_definitions: &mut Vec<talos_core::provider::ToolDefinition>,
1288 presented_tool_names: &mut HashSet<String>,
1289 ) {
1290 let mut changed = false;
1291 for continuation in results
1292 .iter()
1293 .flat_map(|result| result.continuations.iter())
1294 {
1295 if continuation.is_tool_disclosure() {
1296 if !policy.tools.iter().any(|tool| tool == &continuation.tool) {
1297 policy.tools.push(continuation.tool.clone());
1298 changed = true;
1299 }
1300 } else {
1301 let backend = &continuation.backend;
1302 if !policy.allows_backend(&continuation.tool, backend) {
1303 policy
1304 .backends
1305 .push(talos_core::tool::ToolBackendDisclosure::new(
1306 continuation.tool.clone(),
1307 backend.clone(),
1308 ));
1309 changed = true;
1310 }
1311 }
1312 }
1313
1314 if changed {
1315 let (_, definitions, names) = describe_presented_tools(&self.tools, policy);
1316 *tool_definitions = definitions;
1317 *presented_tool_names = names;
1318 }
1319 }
1320}
1321
1322#[allow(warnings)]
1323#[cfg(test)]
1324mod tests;
1325
1326#[cfg(test)]
1327mod i169_shell_compression_regression {
1328 use super::should_compress_shell_output;
1329
1330 #[test]
1331 fn production_shell_compression_predicate_covers_bash_and_powershell_only() {
1332 assert!(should_compress_shell_output("bash"));
1333 assert!(should_compress_shell_output("powershell"));
1334 assert!(!should_compress_shell_output("read"));
1335 assert!(!should_compress_shell_output("fetch_url"));
1336 }
1337}