1use reqwest::Client;
2use serde_json::{json, Value};
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::time::Duration;
6use crate::{Result, RuntimeError, ToolRegistry};
7use std::sync::Mutex;
8use tokio::sync::{mpsc, RwLock};
9use tokio_stream::wrappers::UnboundedReceiverStream;
10use tokio_util::sync::CancellationToken;
11use futures::stream::Stream;
12use std::pin::Pin;
13
14mod types;
15mod auth;
16mod api;
17mod api_sync;
18mod request;
19mod stream;
20mod helpers;
21mod sse;
22mod sse_types;
23pub mod subagent;
24pub mod openai;
25pub mod telemetry;
26pub mod compaction;
27
28pub use types::{StreamEvent, LlmEvent, SessionEvent, AgentEvent};
29use types::AuthState;
30use auth::AuthMethods;
31use api::ApiMethods;
32use stream::StreamMethods;
33use helpers::HelperMethods;
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
56
57pub async fn resolve_before_tool_call_result(
62 hook_result: crate::extensions::hooks::events::HookResult,
63 secret_prompt: Option<&crate::tools::SecretPromptHandle>,
64 auto_approve_confirms: bool,
65) -> crate::extensions::hooks::events::HookResult {
66 match hook_result {
67 crate::extensions::hooks::events::HookResult::Confirm { message } => {
68 if auto_approve_confirms {
69 tracing::info!(message = %message, "confirm auto-approved (auto_approve_confirms=true)");
70 return crate::extensions::hooks::events::HookResult::Continue;
71 }
72
73 let Some(prompt) = secret_prompt else {
74 return crate::extensions::hooks::events::HookResult::Block {
75 reason: format!(
76 "Tool call requires confirmation but no interactive prompt is available: {}",
77 message
78 ),
79 };
80 };
81
82 let response = prompt
83 .prompt(
84 "Confirm tool call".to_string(),
85 format!("{}\n\nType 'yes' or 'y' to allow.", message),
86 )
87 .await;
88
89 match response.as_deref().map(str::trim) {
90 Some(answer) if answer.eq_ignore_ascii_case("yes") || answer.eq_ignore_ascii_case("y") => {
91 crate::extensions::hooks::events::HookResult::Continue
92 }
93 _ => crate::extensions::hooks::events::HookResult::Block {
94 reason: format!("Tool call confirmation denied: {}", message),
95 },
96 }
97 }
98 other => other,
99 }
100}
101
102pub async fn resolve_before_tool_call_decision(
104 original_input: Value,
105 hook_result: crate::extensions::hooks::events::HookResult,
106 secret_prompt: Option<&crate::tools::SecretPromptHandle>,
107 auto_approve_confirms: bool,
108) -> BeforeToolCallDecision {
109 match resolve_before_tool_call_result(hook_result, secret_prompt, auto_approve_confirms).await {
110 crate::extensions::hooks::events::HookResult::Block { reason } => {
111 BeforeToolCallDecision::Block { reason }
112 }
113 crate::extensions::hooks::events::HookResult::Modify { input } => {
114 BeforeToolCallDecision::Continue { input }
115 }
116 _ => BeforeToolCallDecision::Continue { input: original_input },
117 }
118}
119
120pub async fn emit_after_tool_call(
123 hook_bus: &Arc<crate::extensions::hooks::HookBus>,
124 tool_name: &str,
125 runtime_tool_name: Option<&str>,
126 input: Value,
127 output: String,
128) -> crate::extensions::hooks::events::HookResult {
129 let mut event = crate::extensions::hooks::events::HookEvent::after_tool_call(
130 tool_name,
131 input,
132 output,
133 );
134 if let Some(runtime_tool_name) = runtime_tool_name {
135 event.tool_runtime_name = Some(runtime_tool_name.to_string());
136 }
137 hook_bus.emit(&event).await
138}
139
140pub struct Runtime {
143 client: Client,
144 auth: Arc<RwLock<AuthState>>,
145 model: String,
146 tools: Arc<RwLock<ToolRegistry>>,
147 system_prompt: Option<String>,
148 thinking_budget: u32,
149 context_window_override: Option<u64>,
154 compaction_model: Option<String>,
156 subagent_registry: Arc<Mutex<crate::runtime::subagent::SubagentRegistry>>,
158 event_queue: Arc<crate::events::EventQueue>,
160 pub watcher_exit_path: Option<PathBuf>,
162 max_tool_output: usize,
164 bash_timeout: u64,
165 bash_max_timeout: u64,
166 subagent_timeout: u64,
167 api_retries: u32,
168 telemetry_level: crate::runtime::telemetry::TelemetryLevel,
170 cache_diagnostics: bool,
172 cache_ttl: crate::core::config::CacheTtl,
175 ttl_downgrade_notified: std::sync::Arc<std::sync::atomic::AtomicBool>,
178 saw_1h_honored: std::sync::Arc<std::sync::atomic::AtomicBool>,
182 #[allow(dead_code)]
186 last_msg_id: Arc<Mutex<Option<String>>>,
187 session_manager: std::sync::Arc<crate::tools::shell::SessionManager>,
188 hook_bus: Arc<crate::extensions::hooks::HookBus>,
190 #[allow(dead_code)]
192 reaper_handle: Option<tokio::task::JoinHandle<()>>,
193 #[allow(dead_code)]
194 reaper_cancel: Option<tokio_util::sync::CancellationToken>,
195 credential_source: crate::auth::CredentialSource,
199 token_cache: crate::auth::TokenCache,
202}
203
204impl Runtime {
205 pub async fn new() -> Result<Self> {
206 let (auth_token, auth_type, refresh_token, token_expires) = AuthMethods::get_auth_token()?;
207
208 let client = Client::builder()
209 .tls_built_in_webpki_certs(true)
210 .tls_built_in_native_certs(true)
211 .connect_timeout(Duration::from_secs(10))
212 .timeout(Duration::from_secs(300))
213 .build()
214 .map_err(|e| RuntimeError::Config(format!("Failed to build HTTP client: {}", e)))?;
215
216 let session_manager = {
217 let config = crate::tools::shell::ShellConfig::default();
218 crate::tools::shell::SessionManager::new(config)
219 };
220
221 let mgr = session_manager.clone();
223 let cancel = tokio_util::sync::CancellationToken::new();
224 let reaper_handle = crate::tools::shell::session::start_reaper(mgr, cancel.clone());
225
226 Ok(Runtime {
227 client,
228 auth: Arc::new(RwLock::new(AuthState {
229 auth_token,
230 auth_type,
231 refresh_token,
232 token_expires,
233 })),
234 model: crate::models::default_model().to_string(),
235 tools: Arc::new(RwLock::new(ToolRegistry::new())),
236 system_prompt: None,
237 thinking_budget: 4096,
238 context_window_override: None,
239 compaction_model: None,
240 subagent_registry: Arc::new(Mutex::new(crate::runtime::subagent::SubagentRegistry::new())),
241 event_queue: Arc::new(crate::events::EventQueue::new(1000)),
242 watcher_exit_path: None,
243 max_tool_output: 30000,
244 bash_timeout: 30,
245 bash_max_timeout: 300,
246 subagent_timeout: 300,
247 api_retries: 3,
248 telemetry_level: crate::runtime::telemetry::TelemetryLevel::Off,
249 cache_diagnostics: false,
250 cache_ttl: crate::core::config::CacheTtl::default(),
251 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
252 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
253 last_msg_id: Arc::new(Mutex::new(None)),
254 session_manager,
255 hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
256 reaper_handle: Some(reaper_handle),
257 reaper_cancel: Some(cancel),
258 credential_source: crate::auth::CredentialSource::Local,
259 token_cache: crate::auth::TokenCache::new(),
260 })
261 }
262
263 pub fn set_system_prompt(&mut self, prompt: String) {
264 self.system_prompt = Some(prompt);
265 }
266
267 pub fn system_prompt(&self) -> Option<&str> {
268 self.system_prompt.as_deref()
269 }
270
271 pub fn set_model(&mut self, model: String) {
272 let cleaned = if let Some(pos) = model.find("claude-") {
274 model[pos..].to_string()
275 } else if let Some(pos) = model.find('/') {
276 let before = &model[..pos];
277 let key_start = before.rfind(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
278 .map(|i| i + before[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1))
279 .unwrap_or(0);
280 model[key_start..].to_string()
281 } else {
282 model
283 };
284 self.model = cleaned;
285 }
286
287 pub fn set_tools(&mut self, tools: ToolRegistry) {
288 self.tools = Arc::new(RwLock::new(tools));
289 }
290
291 pub fn subagent_registry(&self) -> &Arc<Mutex<crate::runtime::subagent::SubagentRegistry>> {
292 &self.subagent_registry
293 }
294
295 pub fn event_queue(&self) -> &Arc<crate::events::EventQueue> {
296 &self.event_queue
297 }
298
299 pub fn hook_bus(&self) -> &Arc<crate::extensions::hooks::HookBus> {
301 &self.hook_bus
302 }
303
304 pub fn tools_shared(&self) -> Arc<RwLock<ToolRegistry>> {
306 Arc::clone(&self.tools)
307 }
308
309 pub fn model(&self) -> &str {
310 &self.model
311 }
312
313 pub fn http_client(&self) -> &Client {
314 &self.client
315 }
316 pub fn set_thinking_budget(&mut self, budget: u32) {
317 self.thinking_budget = budget;
318 }
319
320 pub fn set_compaction_model(&mut self, model: Option<String>) {
321 self.compaction_model = model;
322 }
323
324 pub fn set_context_window(&mut self, window: Option<u64>) {
325 self.context_window_override = window;
326 }
327
328 pub fn compaction_model(&self) -> &str {
331 self.compaction_model.as_deref().unwrap_or("claude-sonnet-4-6")
332 }
333
334 pub fn context_window(&self) -> u64 {
335 self.context_window_override
336 .unwrap_or_else(|| crate::models::context_window_for_model(&self.model))
337 }
338
339 pub fn apply_config(&mut self, config: &crate::config::SynapsConfig) {
341 if let Some(ref model) = config.model {
342 self.set_model(model.clone());
343 }
344 if let Some(budget) = config.thinking_budget {
345 self.set_thinking_budget(budget);
346 }
347 self.context_window_override = config.context_window;
348 self.compaction_model = config.compaction_model.clone();
349 self.max_tool_output = config.max_tool_output;
350 self.bash_timeout = config.bash_timeout;
351 self.bash_max_timeout = config.bash_max_timeout;
352 self.subagent_timeout = config.subagent_timeout;
353 self.api_retries = config.api_retries;
354 self.telemetry_level = crate::runtime::telemetry::TelemetryLevel::from_str_key(&config.telemetry);
355 self.cache_diagnostics = config.cache_diagnostics;
356 self.cache_ttl = config.cache_ttl;
357 self.apply_auth_config(config);
358
359 if !config.disabled_tools.is_empty() {
363 if let Ok(mut reg) = self.tools.try_write() {
364 reg.disable(&config.disabled_tools);
365 }
366 }
367 }
368
369 pub fn apply_auth_config(&mut self, config: &crate::config::SynapsConfig) {
377 let new_source = config.auth.credential_source();
378 if new_source != self.credential_source {
379 self.token_cache.invalidate("anthropic");
380 }
381 self.credential_source = new_source;
382 if self.credential_source.is_remote() {
383 if let Ok(mut auth) = self.auth.try_write() {
384 AuthMethods::scrub_for_remote(&mut auth);
385 }
386 }
387 }
388
389 pub fn thinking_budget(&self) -> u32 {
390 self.thinking_budget
391 }
392
393 pub fn max_tool_output(&self) -> usize {
394 self.max_tool_output
395 }
396
397 pub fn bash_timeout(&self) -> u64 {
398 self.bash_timeout
399 }
400
401 pub fn bash_max_timeout(&self) -> u64 {
402 self.bash_max_timeout
403 }
404
405 pub fn subagent_timeout(&self) -> u64 {
406 self.subagent_timeout
407 }
408
409 pub fn api_retries(&self) -> u32 {
410 self.api_retries
411 }
412
413 pub fn set_max_tool_output(&mut self, v: usize) {
414 self.max_tool_output = v;
415 }
416
417 pub fn set_bash_timeout(&mut self, v: u64) {
418 self.bash_timeout = v;
419 }
420
421 pub fn set_bash_max_timeout(&mut self, v: u64) {
422 self.bash_max_timeout = v;
423 }
424
425 pub fn set_subagent_timeout(&mut self, v: u64) {
426 self.subagent_timeout = v;
427 }
428
429 pub fn set_api_retries(&mut self, v: u32) {
430 self.api_retries = v;
431 }
432
433 pub fn telemetry_level(&self) -> crate::runtime::telemetry::TelemetryLevel {
434 self.telemetry_level
435 }
436
437 pub fn set_telemetry_level(&mut self, level: crate::runtime::telemetry::TelemetryLevel) {
438 self.telemetry_level = level;
439 }
440
441 pub fn cache_diagnostics(&self) -> bool {
442 self.cache_diagnostics
443 }
444
445 pub fn set_cache_diagnostics(&mut self, v: bool) {
446 self.cache_diagnostics = v;
447 }
448
449 pub fn cache_ttl(&self) -> crate::core::config::CacheTtl {
450 self.cache_ttl
451 }
452
453 pub fn set_cache_ttl(&mut self, ttl: crate::core::config::CacheTtl) {
457 self.cache_ttl = ttl;
458 }
459
460 pub fn thinking_level(&self) -> &str {
461 crate::core::models::thinking_level_for_budget(self.thinking_budget)
462 }
463
464 pub async fn refresh_if_needed(&self) -> Result<()> {
466 if !crate::runtime::auth::model_is_anthropic(&self.model) {
469 return Ok(());
470 }
471 AuthMethods::refresh_if_needed(
472 Arc::clone(&self.auth),
473 &self.client,
474 &self.credential_source,
475 &self.token_cache,
476 )
477 .await
478 }
479
480 pub async fn compact_call(&self, messages: Vec<Value>) -> Result<String> {
486 self.refresh_if_needed().await?;
487
488 use crate::runtime::compaction::COMPACTION_SYSTEM_PROMPT;
489
490 ApiMethods::call_api_simple(
491 &self.auth,
492 &self.client,
493 self.compaction_model(),
494 COMPACTION_SYSTEM_PROMPT,
495 self.thinking_budget,
496 &messages,
497 self.api_retries,
498 ).await
499 }
500
501 pub async fn run_single(&self, prompt: &str) -> Result<String> {
504 self.refresh_if_needed().await?;
506
507 let mut messages = vec![json!({"role": "user", "content": prompt})];
508
509 loop {
510 let response = ApiMethods::call_api(
511 &self.auth,
512 &self.client,
513 &self.model,
514 &*self.tools.read().await,
515 &self.system_prompt,
516 self.thinking_budget,
517 &messages,
518 self.api_retries,
519 &api::ApiOptions {
520 use_1m_context: self.context_window_override == Some(1_000_000),
521 cache_ttl: self.cache_ttl,
522 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
523 saw_1h_honored: self.saw_1h_honored.clone(),
524 credential_source: self.credential_source.clone(),
525 token_cache: self.token_cache.clone(),
526 },
527 ).await?;
528
529 if let Some(content) = response["content"].as_array() {
531 let mut response_text = String::new();
532 let mut tool_uses = Vec::new();
533
534 for item in content {
536 match item["type"].as_str() {
537 Some("text") => {
538 if let Some(text) = item["text"].as_str() {
539 response_text.push_str(text);
540 }
541 }
542 Some("tool_use") => {
543 tool_uses.push(item.clone());
544 }
545 _ => {}
546 }
547 }
548
549 if tool_uses.is_empty() {
551 return Ok(response_text);
552 }
553
554 messages.push(json!({
556 "role": "assistant",
557 "content": content
558 }));
559
560 let mut tool_results = Vec::new();
562
563 if tool_uses.len() == 1 {
564 let tool_use = &tool_uses[0];
566 if let (Some(tool_name), Some(tool_id)) = (
567 tool_use["name"].as_str(),
568 tool_use["id"].as_str()
569 ) {
570 let input = &tool_use["input"];
571 let result = match self.tools.read().await.get(tool_name).cloned() {
572 Some(tool) => {
573 let input = self.tools.read().await.translate_input_for_api_tool(tool_name, input.clone());
574 let runtime_name = self.tools.read().await.runtime_name_for_api(tool_name).to_string();
575 let ctx = crate::ToolContext {
576 channels: crate::tools::ToolChannels {
577 tx_delta: None,
578 tx_events: None,
579 },
580 capabilities: crate::tools::ToolCapabilities {
581 watcher_exit_path: self.watcher_exit_path.clone(),
582 tool_register_tx: None,
583 session_manager: Some(self.session_manager.clone()),
584 subagent_registry: Some(self.subagent_registry.clone()),
585 event_queue: Some(self.event_queue.clone()),
586 secret_prompt: None,
587 },
588 limits: crate::tools::ToolLimits {
589 max_tool_output: self.max_tool_output,
590 bash_timeout: self.bash_timeout,
591 bash_max_timeout: self.bash_max_timeout,
592 subagent_timeout: self.subagent_timeout,
593 },
594 };
595 let decision = resolve_before_tool_call_decision(
596 input.clone(),
597 emit_before_tool_call(
598 &self.hook_bus,
599 tool_name,
600 Some(&runtime_name),
601 input.clone(),
602 ).await,
603 None,
604 false,
605 ).await;
606 if let BeforeToolCallDecision::Block { reason } = decision {
607 format!("Tool call blocked by extension: {}", reason)
608 } else {
609 let BeforeToolCallDecision::Continue { input } = decision else { unreachable!() };
610 let input_for_hook = input.clone();
611 let output = match tool.execute(input, ctx).await {
612 Ok(output) => output,
613 Err(e) => format!("Tool execution failed: {}", e),
614 };
615 let _ = emit_after_tool_call(
616 &self.hook_bus,
617 tool_name,
618 Some(&runtime_name),
619 input_for_hook,
620 output.clone(),
621 ).await;
622 output
623 }
624 }
625 None => format!("Unknown tool: {}", tool_name),
626 };
627 tool_results.push(json!({
628 "type": "tool_result",
629 "tool_use_id": tool_id,
630 "content": HelperMethods::truncate_tool_result(&result, self.max_tool_output)
631 }));
632 }
633 } else {
634 let mut join_set = tokio::task::JoinSet::new();
636
637 let cfg_max_tool_output = self.max_tool_output;
639 let cfg_bash_timeout = self.bash_timeout;
640 let cfg_bash_max_timeout = self.bash_max_timeout;
641 let cfg_subagent_timeout = self.subagent_timeout;
642 let session_mgr = self.session_manager.clone();
643 let cfg_subagent_registry = self.subagent_registry.clone();
644 let cfg_event_queue = self.event_queue.clone();
645 let cfg_hook_bus = self.hook_bus.clone();
646
647 for tool_use in &tool_uses {
648 if let (Some(tool_name), Some(tool_id)) = (
649 tool_use["name"].as_str().map(|s| s.to_string()),
650 tool_use["id"].as_str().map(|s| s.to_string()),
651 ) {
652 let input = tool_use["input"].clone();
653 let tools_snapshot = self.tools.read().await;
654 let input = tools_snapshot.translate_input_for_api_tool(&tool_name, input);
655 let runtime_name = tools_snapshot.runtime_name_for_api(&tool_name).to_string();
656 let tool = tools_snapshot.get(&tool_name).cloned();
657 drop(tools_snapshot);
658 let exit_path = self.watcher_exit_path.clone();
659 let session_mgr_inner = session_mgr.clone();
660 let registry_inner = cfg_subagent_registry.clone();
661 let event_queue_inner = cfg_event_queue.clone();
662 let hook_bus_inner = cfg_hook_bus.clone();
663 let tool_name_for_hook = tool_name.clone();
664 let runtime_name_for_hook = runtime_name.clone();
665
666 join_set.spawn(async move {
667 let result = match tool {
668 Some(t) => {
669 let decision = crate::runtime::resolve_before_tool_call_decision(
670 input.clone(),
671 crate::runtime::emit_before_tool_call(
672 &hook_bus_inner,
673 &tool_name_for_hook,
674 Some(&runtime_name_for_hook),
675 input.clone(),
676 ).await,
677 None,
678 false,
679 ).await;
680 if let crate::runtime::BeforeToolCallDecision::Block { reason } = decision {
681 format!("Tool call blocked by extension: {}", reason)
682 } else {
683 let crate::runtime::BeforeToolCallDecision::Continue { input } = decision else { unreachable!() };
684 let ctx = crate::ToolContext {
685 channels: crate::tools::ToolChannels {
686 tx_delta: None,
687 tx_events: None,
688 },
689 capabilities: crate::tools::ToolCapabilities {
690 watcher_exit_path: exit_path,
691 tool_register_tx: None,
692 session_manager: Some(session_mgr_inner),
693 subagent_registry: Some(registry_inner),
694 event_queue: Some(event_queue_inner),
695 secret_prompt: None,
696 },
697 limits: crate::tools::ToolLimits {
698 max_tool_output: cfg_max_tool_output,
699 bash_timeout: cfg_bash_timeout,
700 bash_max_timeout: cfg_bash_max_timeout,
701 subagent_timeout: cfg_subagent_timeout,
702 },
703 };
704 let input_for_hook = input.clone();
705 let output = match t.execute(input, ctx).await {
706 Ok(output) => output,
707 Err(e) => format!("Tool execution failed: {}", e),
708 };
709 let _ = crate::runtime::emit_after_tool_call(
710 &hook_bus_inner,
711 &tool_name_for_hook,
712 Some(&runtime_name_for_hook),
713 input_for_hook,
714 output.clone(),
715 ).await;
716 output
717 }
718 }
719 None => format!("Unknown tool: {}", tool_name),
720 };
721 (tool_id, result)
722 });
723 }
724 }
725
726 let mut results_map = std::collections::HashMap::new();
728 while let Some(res) = join_set.join_next().await {
729 match res {
730 Ok((tool_id, result)) => {
731 results_map.insert(tool_id, result);
732 }
733 Err(e) => {
734 tracing::error!("Parallel tool task panicked: {}", e);
736 }
737 }
738 }
739
740 for tool_use in &tool_uses {
742 if let Some(tool_id) = tool_use["id"].as_str() {
743 let result = results_map.remove(tool_id)
744 .unwrap_or_else(|| "Tool execution failed: task panicked".to_string());
745 tool_results.push(json!({
746 "type": "tool_result",
747 "tool_use_id": tool_id,
748 "content": HelperMethods::truncate_tool_result(&result, self.max_tool_output)
749 }));
750 }
751 }
752 }
753
754 messages.push(json!({
756 "role": "user",
757 "content": tool_results
758 }));
759
760 } else {
762 return Err(RuntimeError::Tool("Invalid response format".to_string()));
763 }
764 }
765 }
766
767 pub async fn run_stream(&self, prompt: String, cancel: CancellationToken) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
770 self.run_stream_with_messages(vec![json!({"role": "user", "content": prompt})], cancel, None, None, false).await
771 }
772
773 pub async fn run_stream_with_messages(
777 &self,
778 messages: Vec<Value>,
779 cancel: CancellationToken,
780 steering_rx: Option<mpsc::UnboundedReceiver<String>>,
781 secret_prompt: Option<crate::tools::SecretPromptHandle>,
782 auto_approve_confirms: bool,
783 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
784 let (tx, rx) = mpsc::unbounded_channel();
785
786 if let Err(e) = self.refresh_if_needed().await {
788 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(e.to_string())));
789 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
790 return Box::pin(UnboundedReceiverStream::new(rx));
791 }
792
793 let auth = Arc::clone(&self.auth);
796 let client = self.client.clone();
797 let credential_source = self.credential_source.clone();
798 let token_cache = self.token_cache.clone();
799 let model = self.model.clone();
800 let tools = self.tools.clone();
801 let system_prompt = self.system_prompt.clone();
802 let thinking_budget = self.thinking_budget;
803 let watcher_exit_path = self.watcher_exit_path.clone();
804 let max_tool_output = self.max_tool_output;
805 let bash_timeout = self.bash_timeout;
806 let bash_max_timeout = self.bash_max_timeout;
807 let subagent_timeout = self.subagent_timeout;
808 let api_retries = self.api_retries;
809 let session_manager = self.session_manager.clone();
810 let subagent_registry = self.subagent_registry.clone();
814 let event_queue = self.event_queue.clone();
815 let options = api::ApiOptions {
816 use_1m_context: self.context_window_override == Some(1_000_000),
817 cache_ttl: self.cache_ttl,
818 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
819 saw_1h_honored: self.saw_1h_honored.clone(),
820 credential_source: self.credential_source.clone(),
821 token_cache: self.token_cache.clone(),
822 };
823
824 let session = crate::runtime::stream::StreamSession {
825 auth, client, credential_source, token_cache, options, api_retries,
826 model, tools, system_prompt, thinking_budget,
827 tx: tx.clone(), cancel, steering_rx,
828 watcher_exit_path, max_tool_output,
829 bash_timeout, bash_max_timeout, subagent_timeout,
830 session_manager, subagent_registry, event_queue, secret_prompt,
831 hook_bus: self.hook_bus.clone(),
832 auto_approve_confirms,
833 telemetry_level: self.telemetry_level,
834 };
835
836 tokio::spawn(async move {
837 if let Err(e) = StreamMethods::run_stream_internal(session, messages).await {
838 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(e.to_string())));
839 }
840 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
841 });
842
843 Box::pin(UnboundedReceiverStream::new(rx))
844 }
845}
846
847impl Clone for Runtime {
848 fn clone(&self) -> Self {
849 Self {
850 client: self.client.clone(),
851 auth: Arc::clone(&self.auth),
852 model: self.model.clone(),
853 tools: self.tools.clone(),
854 system_prompt: self.system_prompt.clone(),
855 thinking_budget: self.thinking_budget,
856 context_window_override: self.context_window_override,
857 compaction_model: self.compaction_model.clone(),
858 subagent_registry: self.subagent_registry.clone(),
859 event_queue: self.event_queue.clone(),
860 watcher_exit_path: self.watcher_exit_path.clone(),
861 max_tool_output: self.max_tool_output,
862 bash_timeout: self.bash_timeout,
863 bash_max_timeout: self.bash_max_timeout,
864 subagent_timeout: self.subagent_timeout,
865 api_retries: self.api_retries,
866 telemetry_level: self.telemetry_level,
867 cache_diagnostics: self.cache_diagnostics,
868 cache_ttl: self.cache_ttl,
869 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
873 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
874 last_msg_id: Arc::new(Mutex::new(None)),
877 session_manager: self.session_manager.clone(),
878 hook_bus: self.hook_bus.clone(),
879 reaper_handle: None, reaper_cancel: None, credential_source: self.credential_source.clone(),
882 token_cache: self.token_cache.clone(), }
884 }
885}
886
887#[cfg(test)]
888mod tests {
889 use super::*;
890
891 #[tokio::test]
892 async fn confirm_without_prompt_fails_closed() {
893 let result = resolve_before_tool_call_result(
894 crate::extensions::hooks::events::HookResult::Confirm {
895 message: "Run deploy?".into(),
896 },
897 None,
898 false,
899 )
900 .await;
901
902 assert!(matches!(
903 result,
904 crate::extensions::hooks::events::HookResult::Block { reason }
905 if reason.contains("requires confirmation") && reason.contains("Run deploy?")
906 ));
907 }
908
909 #[tokio::test]
910 async fn modify_result_replaces_tool_input() {
911 let result = resolve_before_tool_call_decision(
912 serde_json::json!({"command":"rm -rf /"}),
913 crate::extensions::hooks::events::HookResult::Modify {
914 input: serde_json::json!({"command":"echo safe"}),
915 },
916 None,
917 false,
918 ).await;
919
920 match result {
921 BeforeToolCallDecision::Continue { input } => {
922 assert_eq!(input, serde_json::json!({"command":"echo safe"}));
923 }
924 BeforeToolCallDecision::Block { reason } => panic!("unexpected block: {reason}"),
925 }
926 }
927
928 #[tokio::test]
929 async fn confirm_prompt_yes_continues() {
930 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
931 let handle = crate::tools::SecretPromptHandle::new(tx);
932
933 let task = tokio::spawn(async move {
934 let request = rx.recv().await.expect("confirm prompt request");
935 assert_eq!(request.title, "Confirm tool call");
936 assert!(request.prompt.contains("Run deploy?"));
937 let _ = request.response_tx.send(Some("yes".to_string()));
938 });
939
940 let result = resolve_before_tool_call_result(
941 crate::extensions::hooks::events::HookResult::Confirm {
942 message: "Run deploy?".into(),
943 },
944 Some(&handle),
945 false,
946 )
947 .await;
948
949 task.await.unwrap();
950 assert!(matches!(
951 result,
952 crate::extensions::hooks::events::HookResult::Continue
953 ));
954 }
955
956 #[tokio::test]
957 async fn confirm_prompt_non_yes_blocks() {
958 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
959 let handle = crate::tools::SecretPromptHandle::new(tx);
960
961 let task = tokio::spawn(async move {
962 let request = rx.recv().await.expect("confirm prompt request");
963 let _ = request.response_tx.send(Some("no".to_string()));
964 });
965
966 let result = resolve_before_tool_call_result(
967 crate::extensions::hooks::events::HookResult::Confirm {
968 message: "Run deploy?".into(),
969 },
970 Some(&handle),
971 false,
972 )
973 .await;
974
975 task.await.unwrap();
976 assert!(matches!(
977 result,
978 crate::extensions::hooks::events::HookResult::Block { reason }
979 if reason.contains("confirmation denied")
980 ));
981 }
982
983 #[test]
984 fn test_max_tokens_for_model() {
985 assert_eq!(HelperMethods::max_tokens_for_model("claude-opus-4-6"), 128000);
987 assert_eq!(HelperMethods::max_tokens_for_model("opus-something"), 128000);
988
989 assert_eq!(HelperMethods::max_tokens_for_model("claude-sonnet-4-20250514"), 64000);
991 assert_eq!(HelperMethods::max_tokens_for_model("haiku"), 64000);
992 assert_eq!(HelperMethods::max_tokens_for_model("claude-3-haiku"), 64000);
993 assert_eq!(HelperMethods::max_tokens_for_model("some-other-model"), 64000);
994
995 assert_eq!(HelperMethods::max_tokens_for_model(""), 64000);
997 assert_eq!(HelperMethods::max_tokens_for_model("OPUS"), 64000); assert_eq!(HelperMethods::max_tokens_for_model("model-opus-end"), 128000); }
1000
1001 #[test]
1002 fn test_truncate_tool_result() {
1003 let default_max = 30000;
1004
1005 let short = "This is a short string.";
1007 assert_eq!(HelperMethods::truncate_tool_result(short, default_max), short);
1008
1009 let exact = "x".repeat(30000);
1011 assert_eq!(HelperMethods::truncate_tool_result(&exact, default_max), exact);
1012
1013 let too_long = "x".repeat(30001);
1015 let truncated = HelperMethods::truncate_tool_result(&too_long, default_max);
1016
1017 assert!(truncated.starts_with(&"x".repeat(30000)));
1019
1020 assert!(truncated.contains("[truncated — 30001 total chars, showing first 30000]"));
1022
1023 assert!(truncated.len() > 30000);
1025
1026 let very_long = "a".repeat(50000);
1028 let truncated_very_long = HelperMethods::truncate_tool_result(&very_long, default_max);
1029 assert!(truncated_very_long.contains("[truncated — 50000 total chars, showing first 30000]"));
1030 assert!(truncated_very_long.starts_with(&"a".repeat(30000)));
1031
1032 let custom_truncated = HelperMethods::truncate_tool_result(&very_long, 100);
1034 assert!(custom_truncated.starts_with(&"a".repeat(100)));
1035 assert!(custom_truncated.contains("[truncated — 50000 total chars, showing first 100]"));
1036 }
1037
1038 #[test]
1039 fn test_thinking_level_ranges() {
1040 use crate::core::models::thinking_level_for_budget;
1041
1042 assert_eq!(thinking_level_for_budget(0), "adaptive");
1044
1045 assert_eq!(thinking_level_for_budget(1), "low");
1047 assert_eq!(thinking_level_for_budget(1024), "low");
1048 assert_eq!(thinking_level_for_budget(2048), "low");
1049
1050 assert_eq!(thinking_level_for_budget(2049), "medium");
1052 assert_eq!(thinking_level_for_budget(3000), "medium");
1053 assert_eq!(thinking_level_for_budget(4096), "medium");
1054
1055 assert_eq!(thinking_level_for_budget(4097), "high");
1057 assert_eq!(thinking_level_for_budget(8192), "high");
1058 assert_eq!(thinking_level_for_budget(16384), "high");
1059
1060 assert_eq!(thinking_level_for_budget(16385), "xhigh");
1062 assert_eq!(thinking_level_for_budget(32768), "xhigh");
1063 assert_eq!(thinking_level_for_budget(100000), "xhigh");
1064 }
1065}