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