1use crate::{Result, RuntimeError, ToolRegistry};
2use futures::stream::Stream;
3use reqwest::Client;
4use serde_json::{json, Value};
5use std::path::PathBuf;
6use std::pin::Pin;
7use std::sync::Arc;
8use std::sync::Mutex;
9use std::time::Duration;
10use tokio::sync::{mpsc, RwLock};
11use tokio_stream::wrappers::UnboundedReceiverStream;
12use tokio_util::sync::CancellationToken;
13
14mod api;
15mod api_sync;
16mod auth;
17pub mod compaction;
18pub(crate) mod helpers;
19pub mod openai;
20mod request;
21mod sse;
22mod sse_types;
23mod stream;
24pub mod subagent;
25pub mod telemetry;
26mod types;
27
28use api::ApiMethods;
29use auth::AuthMethods;
30use helpers::HelperMethods;
31use stream::StreamMethods;
32use types::AuthState;
33pub use types::{AgentEvent, LlmEvent, SessionEvent, StreamEvent};
34
35pub enum BeforeToolCallDecision {
37 Continue { input: Value },
38 Block { reason: String },
39}
40
41pub async fn emit_before_tool_call(
44 hook_bus: &Arc<crate::extensions::hooks::HookBus>,
45 tool_name: &str,
46 runtime_tool_name: Option<&str>,
47 input: Value,
48) -> crate::extensions::hooks::events::HookResult {
49 let mut event = crate::extensions::hooks::events::HookEvent::before_tool_call(tool_name, input);
50 if let Some(runtime_tool_name) = runtime_tool_name {
51 event.tool_runtime_name = Some(runtime_tool_name.to_string());
52 }
53 hook_bus.emit(&event).await
54}
55
56pub async fn resolve_before_tool_call_result(
61 hook_result: crate::extensions::hooks::events::HookResult,
62 secret_prompt: Option<&crate::tools::SecretPromptHandle>,
63 auto_approve_confirms: bool,
64) -> crate::extensions::hooks::events::HookResult {
65 match hook_result {
66 crate::extensions::hooks::events::HookResult::Confirm { message } => {
67 if auto_approve_confirms {
68 tracing::info!(message = %message, "confirm auto-approved (auto_approve_confirms=true)");
69 return crate::extensions::hooks::events::HookResult::Continue;
70 }
71
72 let Some(prompt) = secret_prompt else {
73 return crate::extensions::hooks::events::HookResult::Block {
74 reason: format!(
75 "Tool call requires confirmation but no interactive prompt is available: {}",
76 message
77 ),
78 };
79 };
80
81 let response = prompt
82 .prompt(
83 "Confirm tool call".to_string(),
84 format!("{}\n\nType 'yes' or 'y' to allow.", message),
85 )
86 .await;
87
88 match response.as_deref().map(str::trim) {
89 Some(answer)
90 if answer.eq_ignore_ascii_case("yes") || answer.eq_ignore_ascii_case("y") =>
91 {
92 crate::extensions::hooks::events::HookResult::Continue
93 }
94 _ => crate::extensions::hooks::events::HookResult::Block {
95 reason: format!("Tool call confirmation denied: {}", message),
96 },
97 }
98 }
99 other => other,
100 }
101}
102
103pub async fn resolve_before_tool_call_decision(
105 original_input: Value,
106 hook_result: crate::extensions::hooks::events::HookResult,
107 secret_prompt: Option<&crate::tools::SecretPromptHandle>,
108 auto_approve_confirms: bool,
109) -> BeforeToolCallDecision {
110 match resolve_before_tool_call_result(hook_result, secret_prompt, auto_approve_confirms).await {
111 crate::extensions::hooks::events::HookResult::Block { reason } => {
112 BeforeToolCallDecision::Block { reason }
113 }
114 crate::extensions::hooks::events::HookResult::Modify { input } => {
115 BeforeToolCallDecision::Continue { input }
116 }
117 _ => BeforeToolCallDecision::Continue {
118 input: original_input,
119 },
120 }
121}
122
123const MAX_REPLACE_OUTPUT: usize = 1024 * 1024; pub async fn emit_after_tool_call(
148 hook_bus: &Arc<crate::extensions::hooks::HookBus>,
149 tool_name: &str,
150 runtime_tool_name: Option<&str>,
151 input: Value,
152 output: String,
153 max_tool_output: usize,
154) -> String {
155 use crate::extensions::hooks::events::HookResult;
156 let original = output.clone();
158 let mut event =
159 crate::extensions::hooks::events::HookEvent::after_tool_call(tool_name, input, output);
160 if let Some(runtime_tool_name) = runtime_tool_name {
161 event.tool_runtime_name = Some(runtime_tool_name.to_string());
162 }
163 let post_hook = match hook_bus.emit(&event).await {
164 HookResult::Replace { mut output } => {
165 if output.len() > MAX_REPLACE_OUTPUT {
166 tracing::warn!(
167 tool = %tool_name,
168 len = output.len(),
169 cap = MAX_REPLACE_OUTPUT,
170 "Extension Replace output exceeds cap — clamping",
171 );
172 let mut boundary = MAX_REPLACE_OUTPUT;
174 while boundary > 0 && !output.is_char_boundary(boundary) {
175 boundary -= 1;
176 }
177 output.truncate(boundary);
178 }
179 output
180 }
181 HookResult::Continue => original,
182 other => {
183 tracing::warn!(
184 tool = %tool_name,
185 ?other,
186 "Unexpected after_tool_call result — using original output",
187 );
188 original
189 }
190 };
191 crate::runtime::helpers::HelperMethods::truncate_tool_result(&post_hook, max_tool_output)
195}
196
197pub struct Runtime {
200 client: Client,
201 auth: Arc<RwLock<AuthState>>,
202 model: String,
203 tools: Arc<RwLock<ToolRegistry>>,
204 system_prompt: Option<String>,
205 thinking_budget: u32,
206 context_window_override: Option<u64>,
211 compaction_model: Option<String>,
213 subagent_registry: Arc<Mutex<crate::runtime::subagent::SubagentRegistry>>,
215 event_queue: Arc<crate::events::EventQueue>,
217 pub watcher_exit_path: Option<PathBuf>,
219 max_tool_output: usize,
221 bash_timeout: u64,
222 bash_max_timeout: u64,
223 subagent_timeout: u64,
224 api_retries: u32,
225 telemetry_level: crate::runtime::telemetry::TelemetryLevel,
227 cache_diagnostics: bool,
229 cache_ttl: crate::core::config::CacheTtl,
232 ttl_downgrade_notified: std::sync::Arc<std::sync::atomic::AtomicBool>,
235 saw_1h_honored: std::sync::Arc<std::sync::atomic::AtomicBool>,
239 #[allow(dead_code)]
243 last_msg_id: Arc<Mutex<Option<String>>>,
244 session_manager: std::sync::Arc<crate::tools::shell::SessionManager>,
245 hook_bus: Arc<crate::extensions::hooks::HookBus>,
247 #[allow(dead_code)]
249 reaper_handle: Option<tokio::task::JoinHandle<()>>,
250 #[allow(dead_code)]
251 reaper_cancel: Option<tokio_util::sync::CancellationToken>,
252 credential_source: crate::auth::CredentialSource,
256 token_cache: crate::auth::TokenCache,
259}
260
261impl Runtime {
262 pub async fn new() -> Result<Self> {
263 let (auth_token, auth_type, refresh_token, token_expires) = AuthMethods::get_auth_token()?;
264
265 let client = Client::builder()
266 .tls_built_in_webpki_certs(true)
267 .tls_built_in_native_certs(true)
268 .connect_timeout(Duration::from_secs(10))
269 .timeout(Duration::from_secs(300))
270 .build()
271 .map_err(|e| RuntimeError::Config(format!("Failed to build HTTP client: {}", e)))?;
272
273 let session_manager = {
274 let config = crate::tools::shell::ShellConfig::default();
275 crate::tools::shell::SessionManager::new(config)
276 };
277
278 let mgr = session_manager.clone();
280 let cancel = tokio_util::sync::CancellationToken::new();
281 let reaper_handle = crate::tools::shell::session::start_reaper(mgr, cancel.clone());
282
283 Ok(Runtime {
284 client,
285 auth: Arc::new(RwLock::new(AuthState {
286 auth_token,
287 auth_type,
288 refresh_token,
289 token_expires,
290 })),
291 model: crate::models::default_model().to_string(),
292 tools: Arc::new(RwLock::new(ToolRegistry::new())),
293 system_prompt: None,
294 thinking_budget: 4096,
295 context_window_override: None,
296 compaction_model: None,
297 subagent_registry: Arc::new(Mutex::new(
298 crate::runtime::subagent::SubagentRegistry::new(),
299 )),
300 event_queue: Arc::new(crate::events::EventQueue::new(1000)),
301 watcher_exit_path: None,
302 max_tool_output: 30000,
303 bash_timeout: 30,
304 bash_max_timeout: 300,
305 subagent_timeout: 300,
306 api_retries: 3,
307 telemetry_level: crate::runtime::telemetry::TelemetryLevel::Off,
308 cache_diagnostics: false,
309 cache_ttl: crate::core::config::CacheTtl::default(),
310 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
311 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
312 last_msg_id: Arc::new(Mutex::new(None)),
313 session_manager,
314 hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
315 reaper_handle: Some(reaper_handle),
316 reaper_cancel: Some(cancel),
317 credential_source: crate::auth::CredentialSource::Local,
318 token_cache: crate::auth::TokenCache::new(),
319 })
320 }
321
322 pub fn set_system_prompt(&mut self, prompt: String) {
323 self.system_prompt = Some(prompt);
324 }
325
326 pub fn system_prompt(&self) -> Option<&str> {
327 self.system_prompt.as_deref()
328 }
329
330 pub fn set_model(&mut self, model: String) {
331 let cleaned = if let Some(pos) = model.find("claude-") {
333 model[pos..].to_string()
334 } else if let Some(pos) = model.find('/') {
335 let before = &model[..pos];
336 let key_start = before
337 .rfind(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
338 .map(|i| {
339 i + before[i..]
340 .chars()
341 .next()
342 .map(|c| c.len_utf8())
343 .unwrap_or(1)
344 })
345 .unwrap_or(0);
346 model[key_start..].to_string()
347 } else {
348 model
349 };
350 self.model = cleaned;
351 }
352
353 pub fn set_tools(&mut self, tools: ToolRegistry) {
354 self.tools = Arc::new(RwLock::new(tools));
355 }
356
357 pub fn subagent_registry(&self) -> &Arc<Mutex<crate::runtime::subagent::SubagentRegistry>> {
358 &self.subagent_registry
359 }
360
361 pub fn event_queue(&self) -> &Arc<crate::events::EventQueue> {
362 &self.event_queue
363 }
364
365 pub fn hook_bus(&self) -> &Arc<crate::extensions::hooks::HookBus> {
367 &self.hook_bus
368 }
369
370 pub fn tools_shared(&self) -> Arc<RwLock<ToolRegistry>> {
372 Arc::clone(&self.tools)
373 }
374
375 pub fn model(&self) -> &str {
376 &self.model
377 }
378
379 pub fn http_client(&self) -> &Client {
380 &self.client
381 }
382 pub fn set_thinking_budget(&mut self, budget: u32) {
383 self.thinking_budget = budget;
384 }
385
386 pub fn set_compaction_model(&mut self, model: Option<String>) {
387 self.compaction_model = model;
388 }
389
390 pub fn set_context_window(&mut self, window: Option<u64>) {
391 self.context_window_override = window;
392 }
393
394 pub fn compaction_model(&self) -> &str {
397 self.compaction_model
398 .as_deref()
399 .unwrap_or("claude-sonnet-4-6")
400 }
401
402 pub fn context_window(&self) -> u64 {
403 self.context_window_override
404 .unwrap_or_else(|| crate::models::context_window_for_model(&self.model))
405 }
406
407 pub fn apply_config(&mut self, config: &crate::config::SynapsConfig) {
409 if let Some(ref model) = config.model {
410 self.set_model(model.clone());
411 }
412 if let Some(budget) = config.thinking_budget {
413 self.set_thinking_budget(budget);
414 }
415 self.context_window_override = config.context_window;
416 self.compaction_model = config.compaction_model.clone();
417 self.max_tool_output = config.max_tool_output;
418 self.bash_timeout = config.bash_timeout;
419 self.bash_max_timeout = config.bash_max_timeout;
420 self.subagent_timeout = config.subagent_timeout;
421 self.api_retries = config.api_retries;
422 self.telemetry_level =
423 crate::runtime::telemetry::TelemetryLevel::from_str_key(&config.telemetry);
424 self.cache_diagnostics = config.cache_diagnostics;
425 self.cache_ttl = config.cache_ttl;
426 self.apply_auth_config(config);
427
428 if !config.disabled_tools.is_empty() {
432 if let Ok(mut reg) = self.tools.try_write() {
433 reg.disable(&config.disabled_tools);
434 }
435 }
436 }
437
438 pub fn apply_auth_config(&mut self, config: &crate::config::SynapsConfig) {
446 let new_source = config.auth.credential_source();
447 if new_source != self.credential_source {
448 self.token_cache.invalidate("anthropic");
449 }
450 self.credential_source = new_source;
451 if self.credential_source.is_remote() {
452 if let Ok(mut auth) = self.auth.try_write() {
453 AuthMethods::scrub_for_remote(&mut auth);
454 }
455 }
456 }
457
458 pub fn thinking_budget(&self) -> u32 {
459 self.thinking_budget
460 }
461
462 pub fn max_tool_output(&self) -> usize {
463 self.max_tool_output
464 }
465
466 pub fn bash_timeout(&self) -> u64 {
467 self.bash_timeout
468 }
469
470 pub fn bash_max_timeout(&self) -> u64 {
471 self.bash_max_timeout
472 }
473
474 pub fn subagent_timeout(&self) -> u64 {
475 self.subagent_timeout
476 }
477
478 pub fn api_retries(&self) -> u32 {
479 self.api_retries
480 }
481
482 pub fn set_max_tool_output(&mut self, v: usize) {
483 self.max_tool_output = v;
484 }
485
486 pub fn set_bash_timeout(&mut self, v: u64) {
487 self.bash_timeout = v;
488 }
489
490 pub fn set_bash_max_timeout(&mut self, v: u64) {
491 self.bash_max_timeout = v;
492 }
493
494 pub fn set_subagent_timeout(&mut self, v: u64) {
495 self.subagent_timeout = v;
496 }
497
498 pub fn set_api_retries(&mut self, v: u32) {
499 self.api_retries = v;
500 }
501
502 pub fn telemetry_level(&self) -> crate::runtime::telemetry::TelemetryLevel {
503 self.telemetry_level
504 }
505
506 pub fn set_telemetry_level(&mut self, level: crate::runtime::telemetry::TelemetryLevel) {
507 self.telemetry_level = level;
508 }
509
510 pub fn cache_diagnostics(&self) -> bool {
511 self.cache_diagnostics
512 }
513
514 pub fn set_cache_diagnostics(&mut self, v: bool) {
515 self.cache_diagnostics = v;
516 }
517
518 pub fn cache_ttl(&self) -> crate::core::config::CacheTtl {
519 self.cache_ttl
520 }
521
522 pub fn set_cache_ttl(&mut self, ttl: crate::core::config::CacheTtl) {
526 self.cache_ttl = ttl;
527 }
528
529 pub fn thinking_level(&self) -> &str {
530 crate::core::models::thinking_level_for_budget(self.thinking_budget)
531 }
532
533 pub async fn refresh_if_needed(&self) -> Result<()> {
535 if !crate::runtime::auth::model_is_anthropic(&self.model) {
538 return Ok(());
539 }
540 AuthMethods::refresh_if_needed(
541 Arc::clone(&self.auth),
542 &self.client,
543 &self.credential_source,
544 &self.token_cache,
545 )
546 .await
547 }
548
549 pub async fn compact_call(&self, messages: Vec<Value>) -> Result<String> {
555 self.refresh_if_needed().await?;
556
557 use crate::runtime::compaction::COMPACTION_SYSTEM_PROMPT;
558
559 ApiMethods::call_api_simple(
560 &self.auth,
561 &self.client,
562 self.compaction_model(),
563 COMPACTION_SYSTEM_PROMPT,
564 self.thinking_budget,
565 &messages,
566 self.api_retries,
567 )
568 .await
569 }
570
571 pub async fn run_single(&self, prompt: &str) -> Result<String> {
574 self.refresh_if_needed().await?;
576
577 let mut messages = vec![json!({"role": "user", "content": prompt})];
578
579 loop {
580 let response = ApiMethods::call_api(
581 &self.auth,
582 &self.client,
583 &self.model,
584 &*self.tools.read().await,
585 &self.system_prompt,
586 self.thinking_budget,
587 &messages,
588 self.api_retries,
589 &api::ApiOptions {
590 use_1m_context: self.context_window_override == Some(1_000_000),
591 cache_ttl: self.cache_ttl,
592 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
593 saw_1h_honored: self.saw_1h_honored.clone(),
594 credential_source: self.credential_source.clone(),
595 token_cache: self.token_cache.clone(),
596 },
597 )
598 .await?;
599
600 if let Some(content) = response["content"].as_array() {
602 let mut response_text = String::new();
603 let mut tool_uses = Vec::new();
604
605 for item in content {
607 match item["type"].as_str() {
608 Some("text") => {
609 if let Some(text) = item["text"].as_str() {
610 response_text.push_str(text);
611 }
612 }
613 Some("tool_use") => {
614 tool_uses.push(item.clone());
615 }
616 _ => {}
617 }
618 }
619
620 if tool_uses.is_empty() {
622 return Ok(response_text);
623 }
624
625 messages.push(json!({
627 "role": "assistant",
628 "content": content
629 }));
630
631 let mut tool_results = Vec::new();
633
634 if tool_uses.len() == 1 {
635 let tool_use = &tool_uses[0];
637 if let (Some(tool_name), Some(tool_id)) =
638 (tool_use["name"].as_str(), tool_use["id"].as_str())
639 {
640 let input = &tool_use["input"];
641 let result = match self.tools.read().await.get(tool_name).cloned() {
642 Some(tool) => {
643 let input = self
644 .tools
645 .read()
646 .await
647 .translate_input_for_api_tool(tool_name, input.clone());
648 let runtime_name = self
649 .tools
650 .read()
651 .await
652 .runtime_name_for_api(tool_name)
653 .to_string();
654 let ctx = crate::ToolContext {
655 channels: crate::tools::ToolChannels {
656 tx_delta: None,
657 tx_events: None,
658 },
659 capabilities: crate::tools::ToolCapabilities {
660 watcher_exit_path: self.watcher_exit_path.clone(),
661 tool_register_tx: None,
662 session_manager: Some(self.session_manager.clone()),
663 subagent_registry: Some(self.subagent_registry.clone()),
664 event_queue: Some(self.event_queue.clone()),
665 secret_prompt: None,
666 },
667 limits: crate::tools::ToolLimits {
668 max_tool_output: self.max_tool_output,
669 max_tool_buffer: 256 * 1024,
670 bash_timeout: self.bash_timeout,
671 bash_max_timeout: self.bash_max_timeout,
672 subagent_timeout: self.subagent_timeout,
673 },
674 };
675 let decision = resolve_before_tool_call_decision(
676 input.clone(),
677 emit_before_tool_call(
678 &self.hook_bus,
679 tool_name,
680 Some(&runtime_name),
681 input.clone(),
682 )
683 .await,
684 None,
685 false,
686 )
687 .await;
688 if let BeforeToolCallDecision::Block { reason } = decision {
689 format!("Tool call blocked by extension: {}", reason)
690 } else {
691 let BeforeToolCallDecision::Continue { input } = decision
692 else {
693 unreachable!()
694 };
695 let input_for_hook = input.clone();
696 let output = match tool.execute(input, ctx).await {
697 Ok(output) => output,
698 Err(e) => format!("Tool execution failed: {}", e),
699 };
700 let output = emit_after_tool_call(
701 &self.hook_bus,
702 tool_name,
703 Some(&runtime_name),
704 input_for_hook,
705 output,
706 self.max_tool_output,
707 )
708 .await;
709 output
710 }
711 }
712 None => format!("Unknown tool: {}", tool_name),
713 };
714 tool_results.push(json!({
715 "type": "tool_result",
716 "tool_use_id": tool_id,
717 "content": HelperMethods::truncate_tool_result(&result, self.max_tool_output)
718 }));
719 }
720 } else {
721 let mut join_set = tokio::task::JoinSet::new();
723
724 let cfg_max_tool_output = self.max_tool_output;
726 let cfg_bash_timeout = self.bash_timeout;
727 let cfg_bash_max_timeout = self.bash_max_timeout;
728 let cfg_subagent_timeout = self.subagent_timeout;
729 let session_mgr = self.session_manager.clone();
730 let cfg_subagent_registry = self.subagent_registry.clone();
731 let cfg_event_queue = self.event_queue.clone();
732 let cfg_hook_bus = self.hook_bus.clone();
733
734 for tool_use in &tool_uses {
735 if let (Some(tool_name), Some(tool_id)) = (
736 tool_use["name"].as_str().map(|s| s.to_string()),
737 tool_use["id"].as_str().map(|s| s.to_string()),
738 ) {
739 let input = tool_use["input"].clone();
740 let tools_snapshot = self.tools.read().await;
741 let input =
742 tools_snapshot.translate_input_for_api_tool(&tool_name, input);
743 let runtime_name =
744 tools_snapshot.runtime_name_for_api(&tool_name).to_string();
745 let tool = tools_snapshot.get(&tool_name).cloned();
746 drop(tools_snapshot);
747 let exit_path = self.watcher_exit_path.clone();
748 let session_mgr_inner = session_mgr.clone();
749 let registry_inner = cfg_subagent_registry.clone();
750 let event_queue_inner = cfg_event_queue.clone();
751 let hook_bus_inner = cfg_hook_bus.clone();
752 let tool_name_for_hook = tool_name.clone();
753 let runtime_name_for_hook = runtime_name.clone();
754
755 join_set.spawn(async move {
756 let result = match tool {
757 Some(t) => {
758 let decision =
759 crate::runtime::resolve_before_tool_call_decision(
760 input.clone(),
761 crate::runtime::emit_before_tool_call(
762 &hook_bus_inner,
763 &tool_name_for_hook,
764 Some(&runtime_name_for_hook),
765 input.clone(),
766 )
767 .await,
768 None,
769 false,
770 )
771 .await;
772 if let crate::runtime::BeforeToolCallDecision::Block {
773 reason,
774 } = decision
775 {
776 format!("Tool call blocked by extension: {}", reason)
777 } else {
778 let crate::runtime::BeforeToolCallDecision::Continue {
779 input,
780 } = decision
781 else {
782 unreachable!()
783 };
784 let ctx = crate::ToolContext {
785 channels: crate::tools::ToolChannels {
786 tx_delta: None,
787 tx_events: None,
788 },
789 capabilities: crate::tools::ToolCapabilities {
790 watcher_exit_path: exit_path,
791 tool_register_tx: None,
792 session_manager: Some(session_mgr_inner),
793 subagent_registry: Some(registry_inner),
794 event_queue: Some(event_queue_inner),
795 secret_prompt: None,
796 },
797 limits: crate::tools::ToolLimits {
798 max_tool_output: cfg_max_tool_output,
799 max_tool_buffer: 256 * 1024,
800 bash_timeout: cfg_bash_timeout,
801 bash_max_timeout: cfg_bash_max_timeout,
802 subagent_timeout: cfg_subagent_timeout,
803 },
804 };
805 let input_for_hook = input.clone();
806 let output = match t.execute(input, ctx).await {
807 Ok(output) => output,
808 Err(e) => format!("Tool execution failed: {}", e),
809 };
810 let output = crate::runtime::emit_after_tool_call(
811 &hook_bus_inner,
812 &tool_name_for_hook,
813 Some(&runtime_name_for_hook),
814 input_for_hook,
815 output,
816 cfg_max_tool_output,
817 )
818 .await;
819 output
820 }
821 }
822 None => format!("Unknown tool: {}", tool_name),
823 };
824 (tool_id, result)
825 });
826 }
827 }
828
829 let mut results_map = std::collections::HashMap::new();
831 while let Some(res) = join_set.join_next().await {
832 match res {
833 Ok((tool_id, result)) => {
834 results_map.insert(tool_id, result);
835 }
836 Err(e) => {
837 tracing::error!("Parallel tool task panicked: {}", e);
839 }
840 }
841 }
842
843 for tool_use in &tool_uses {
845 if let Some(tool_id) = tool_use["id"].as_str() {
846 let result = results_map.remove(tool_id).unwrap_or_else(|| {
847 "Tool execution failed: task panicked".to_string()
848 });
849 tool_results.push(json!({
850 "type": "tool_result",
851 "tool_use_id": tool_id,
852 "content": HelperMethods::truncate_tool_result(&result, self.max_tool_output)
853 }));
854 }
855 }
856 }
857
858 messages.push(json!({
860 "role": "user",
861 "content": tool_results
862 }));
863
864 } else {
866 return Err(RuntimeError::Tool("Invalid response format".to_string()));
867 }
868 }
869 }
870
871 pub async fn run_stream(
874 &self,
875 prompt: String,
876 cancel: CancellationToken,
877 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
878 self.run_stream_with_messages(
879 vec![json!({"role": "user", "content": prompt})],
880 cancel,
881 None,
882 None,
883 false,
884 )
885 .await
886 }
887
888 pub async fn run_stream_with_messages(
892 &self,
893 messages: Vec<Value>,
894 cancel: CancellationToken,
895 steering_rx: Option<mpsc::UnboundedReceiver<String>>,
896 secret_prompt: Option<crate::tools::SecretPromptHandle>,
897 auto_approve_confirms: bool,
898 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
899 let (tx, rx) = mpsc::unbounded_channel();
900
901 if let Err(e) = self.refresh_if_needed().await {
903 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(e.to_string())));
904 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
905 return Box::pin(UnboundedReceiverStream::new(rx));
906 }
907
908 let auth = Arc::clone(&self.auth);
911 let client = self.client.clone();
912 let credential_source = self.credential_source.clone();
913 let token_cache = self.token_cache.clone();
914 let model = self.model.clone();
915 let tools = self.tools.clone();
916 let system_prompt = self.system_prompt.clone();
917 let thinking_budget = self.thinking_budget;
918 let watcher_exit_path = self.watcher_exit_path.clone();
919 let max_tool_output = self.max_tool_output;
920 let bash_timeout = self.bash_timeout;
921 let bash_max_timeout = self.bash_max_timeout;
922 let subagent_timeout = self.subagent_timeout;
923 let api_retries = self.api_retries;
924 let session_manager = self.session_manager.clone();
925 let subagent_registry = self.subagent_registry.clone();
929 let event_queue = self.event_queue.clone();
930 let options = api::ApiOptions {
931 use_1m_context: self.context_window_override == Some(1_000_000),
932 cache_ttl: self.cache_ttl,
933 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
934 saw_1h_honored: self.saw_1h_honored.clone(),
935 credential_source: self.credential_source.clone(),
936 token_cache: self.token_cache.clone(),
937 };
938
939 let session = crate::runtime::stream::StreamSession {
940 auth,
941 client,
942 credential_source,
943 token_cache,
944 options,
945 api_retries,
946 model,
947 tools,
948 system_prompt,
949 thinking_budget,
950 tx: tx.clone(),
951 cancel,
952 steering_rx,
953 watcher_exit_path,
954 max_tool_output,
955 bash_timeout,
956 bash_max_timeout,
957 subagent_timeout,
958 session_manager,
959 subagent_registry,
960 event_queue,
961 secret_prompt,
962 hook_bus: self.hook_bus.clone(),
963 auto_approve_confirms,
964 telemetry_level: self.telemetry_level,
965 };
966
967 tokio::spawn(async move {
968 if let Err(e) = StreamMethods::run_stream_internal(session, messages).await {
969 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(e.to_string())));
970 }
971 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
972 });
973
974 Box::pin(UnboundedReceiverStream::new(rx))
975 }
976}
977
978impl Clone for Runtime {
979 fn clone(&self) -> Self {
980 Self {
981 client: self.client.clone(),
982 auth: Arc::clone(&self.auth),
983 model: self.model.clone(),
984 tools: self.tools.clone(),
985 system_prompt: self.system_prompt.clone(),
986 thinking_budget: self.thinking_budget,
987 context_window_override: self.context_window_override,
988 compaction_model: self.compaction_model.clone(),
989 subagent_registry: self.subagent_registry.clone(),
990 event_queue: self.event_queue.clone(),
991 watcher_exit_path: self.watcher_exit_path.clone(),
992 max_tool_output: self.max_tool_output,
993 bash_timeout: self.bash_timeout,
994 bash_max_timeout: self.bash_max_timeout,
995 subagent_timeout: self.subagent_timeout,
996 api_retries: self.api_retries,
997 telemetry_level: self.telemetry_level,
998 cache_diagnostics: self.cache_diagnostics,
999 cache_ttl: self.cache_ttl,
1000 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1004 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1005 last_msg_id: Arc::new(Mutex::new(None)),
1008 session_manager: self.session_manager.clone(),
1009 hook_bus: self.hook_bus.clone(),
1010 reaper_handle: None, reaper_cancel: None, credential_source: self.credential_source.clone(),
1013 token_cache: self.token_cache.clone(), }
1015 }
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020 use super::*;
1021
1022 #[tokio::test]
1023 async fn confirm_without_prompt_fails_closed() {
1024 let result = resolve_before_tool_call_result(
1025 crate::extensions::hooks::events::HookResult::Confirm {
1026 message: "Run deploy?".into(),
1027 },
1028 None,
1029 false,
1030 )
1031 .await;
1032
1033 assert!(matches!(
1034 result,
1035 crate::extensions::hooks::events::HookResult::Block { reason }
1036 if reason.contains("requires confirmation") && reason.contains("Run deploy?")
1037 ));
1038 }
1039
1040 #[tokio::test]
1041 async fn modify_result_replaces_tool_input() {
1042 let result = resolve_before_tool_call_decision(
1043 serde_json::json!({"command":"rm -rf /"}),
1044 crate::extensions::hooks::events::HookResult::Modify {
1045 input: serde_json::json!({"command":"echo safe"}),
1046 },
1047 None,
1048 false,
1049 )
1050 .await;
1051
1052 match result {
1053 BeforeToolCallDecision::Continue { input } => {
1054 assert_eq!(input, serde_json::json!({"command":"echo safe"}));
1055 }
1056 BeforeToolCallDecision::Block { reason } => panic!("unexpected block: {reason}"),
1057 }
1058 }
1059
1060 #[tokio::test]
1061 async fn confirm_prompt_yes_continues() {
1062 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1063 let handle = crate::tools::SecretPromptHandle::new(tx);
1064
1065 let task = tokio::spawn(async move {
1066 let request = rx.recv().await.expect("confirm prompt request");
1067 assert_eq!(request.title, "Confirm tool call");
1068 assert!(request.prompt.contains("Run deploy?"));
1069 let _ = request.response_tx.send(Some("yes".to_string()));
1070 });
1071
1072 let result = resolve_before_tool_call_result(
1073 crate::extensions::hooks::events::HookResult::Confirm {
1074 message: "Run deploy?".into(),
1075 },
1076 Some(&handle),
1077 false,
1078 )
1079 .await;
1080
1081 task.await.unwrap();
1082 assert!(matches!(
1083 result,
1084 crate::extensions::hooks::events::HookResult::Continue
1085 ));
1086 }
1087
1088 #[tokio::test]
1089 async fn confirm_prompt_non_yes_blocks() {
1090 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1091 let handle = crate::tools::SecretPromptHandle::new(tx);
1092
1093 let task = tokio::spawn(async move {
1094 let request = rx.recv().await.expect("confirm prompt request");
1095 let _ = request.response_tx.send(Some("no".to_string()));
1096 });
1097
1098 let result = resolve_before_tool_call_result(
1099 crate::extensions::hooks::events::HookResult::Confirm {
1100 message: "Run deploy?".into(),
1101 },
1102 Some(&handle),
1103 false,
1104 )
1105 .await;
1106
1107 task.await.unwrap();
1108 assert!(matches!(
1109 result,
1110 crate::extensions::hooks::events::HookResult::Block { reason }
1111 if reason.contains("confirmation denied")
1112 ));
1113 }
1114
1115 #[test]
1116 fn test_max_tokens_for_model() {
1117 assert_eq!(
1119 HelperMethods::max_tokens_for_model("claude-opus-4-6"),
1120 128000
1121 );
1122 assert_eq!(
1123 HelperMethods::max_tokens_for_model("opus-something"),
1124 128000
1125 );
1126
1127 assert_eq!(
1129 HelperMethods::max_tokens_for_model("claude-sonnet-4-20250514"),
1130 64000
1131 );
1132 assert_eq!(HelperMethods::max_tokens_for_model("haiku"), 64000);
1133 assert_eq!(HelperMethods::max_tokens_for_model("claude-3-haiku"), 64000);
1134 assert_eq!(
1135 HelperMethods::max_tokens_for_model("some-other-model"),
1136 64000
1137 );
1138
1139 assert_eq!(HelperMethods::max_tokens_for_model(""), 64000);
1141 assert_eq!(HelperMethods::max_tokens_for_model("OPUS"), 64000); assert_eq!(
1143 HelperMethods::max_tokens_for_model("model-opus-end"),
1144 128000
1145 ); }
1147
1148 #[test]
1149 fn test_truncate_tool_result() {
1150 let default_max = 30000;
1151
1152 let short = "This is a short string.";
1154 assert_eq!(
1155 HelperMethods::truncate_tool_result(short, default_max),
1156 short
1157 );
1158
1159 let exact = "x".repeat(30000);
1161 assert_eq!(
1162 HelperMethods::truncate_tool_result(&exact, default_max),
1163 exact
1164 );
1165
1166 let too_long = "x".repeat(30001);
1168 let truncated = HelperMethods::truncate_tool_result(&too_long, default_max);
1169
1170 assert!(truncated.starts_with(&"x".repeat(30000)));
1172
1173 assert!(truncated.contains("[truncated — 30001 total chars, showing first 30000]"));
1175
1176 assert!(truncated.len() > 30000);
1178
1179 let very_long = "a".repeat(50000);
1181 let truncated_very_long = HelperMethods::truncate_tool_result(&very_long, default_max);
1182 assert!(
1183 truncated_very_long.contains("[truncated — 50000 total chars, showing first 30000]")
1184 );
1185 assert!(truncated_very_long.starts_with(&"a".repeat(30000)));
1186
1187 let custom_truncated = HelperMethods::truncate_tool_result(&very_long, 100);
1189 assert!(custom_truncated.starts_with(&"a".repeat(100)));
1190 assert!(custom_truncated.contains("[truncated — 50000 total chars, showing first 100]"));
1191 }
1192
1193 #[test]
1194 fn test_thinking_level_ranges() {
1195 use crate::core::models::thinking_level_for_budget;
1196
1197 assert_eq!(thinking_level_for_budget(0), "adaptive");
1199
1200 assert_eq!(thinking_level_for_budget(1), "low");
1202 assert_eq!(thinking_level_for_budget(1024), "low");
1203 assert_eq!(thinking_level_for_budget(2048), "low");
1204
1205 assert_eq!(thinking_level_for_budget(2049), "medium");
1207 assert_eq!(thinking_level_for_budget(3000), "medium");
1208 assert_eq!(thinking_level_for_budget(4096), "medium");
1209
1210 assert_eq!(thinking_level_for_budget(4097), "high");
1212 assert_eq!(thinking_level_for_budget(8192), "high");
1213 assert_eq!(thinking_level_for_budget(16384), "high");
1214
1215 assert_eq!(thinking_level_for_budget(16385), "xhigh");
1217 assert_eq!(thinking_level_for_budget(32768), "xhigh");
1218 assert_eq!(thinking_level_for_budget(100000), "xhigh");
1219 }
1220}