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