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}
196
197impl Runtime {
198 pub async fn new() -> Result<Self> {
199 let (auth_token, auth_type, refresh_token, token_expires) = AuthMethods::get_auth_token()?;
200
201 let client = Client::builder()
202 .tls_built_in_webpki_certs(true)
203 .tls_built_in_native_certs(true)
204 .connect_timeout(Duration::from_secs(10))
205 .timeout(Duration::from_secs(300))
206 .build()
207 .map_err(|e| RuntimeError::Config(format!("Failed to build HTTP client: {}", e)))?;
208
209 let session_manager = {
210 let config = crate::tools::shell::ShellConfig::default();
211 crate::tools::shell::SessionManager::new(config)
212 };
213
214 let mgr = session_manager.clone();
216 let cancel = tokio_util::sync::CancellationToken::new();
217 let reaper_handle = crate::tools::shell::session::start_reaper(mgr, cancel.clone());
218
219 Ok(Runtime {
220 client,
221 auth: Arc::new(RwLock::new(AuthState {
222 auth_token,
223 auth_type,
224 refresh_token,
225 token_expires,
226 })),
227 model: crate::models::default_model().to_string(),
228 tools: Arc::new(RwLock::new(ToolRegistry::new())),
229 system_prompt: None,
230 thinking_budget: 4096,
231 context_window_override: None,
232 compaction_model: None,
233 subagent_registry: Arc::new(Mutex::new(crate::runtime::subagent::SubagentRegistry::new())),
234 event_queue: Arc::new(crate::events::EventQueue::new(1000)),
235 watcher_exit_path: None,
236 max_tool_output: 30000,
237 bash_timeout: 30,
238 bash_max_timeout: 300,
239 subagent_timeout: 300,
240 api_retries: 3,
241 telemetry_level: crate::runtime::telemetry::TelemetryLevel::Off,
242 cache_diagnostics: false,
243 cache_ttl: crate::core::config::CacheTtl::default(),
244 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
245 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
246 last_msg_id: Arc::new(Mutex::new(None)),
247 session_manager,
248 hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
249 reaper_handle: Some(reaper_handle),
250 reaper_cancel: Some(cancel),
251 })
252 }
253
254 pub fn set_system_prompt(&mut self, prompt: String) {
255 self.system_prompt = Some(prompt);
256 }
257
258 pub fn system_prompt(&self) -> Option<&str> {
259 self.system_prompt.as_deref()
260 }
261
262 pub fn set_model(&mut self, model: String) {
263 let cleaned = if let Some(pos) = model.find("claude-") {
265 model[pos..].to_string()
266 } else if let Some(pos) = model.find('/') {
267 let before = &model[..pos];
268 let key_start = before.rfind(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
269 .map(|i| i + before[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1))
270 .unwrap_or(0);
271 model[key_start..].to_string()
272 } else {
273 model
274 };
275 self.model = cleaned;
276 }
277
278 pub fn set_tools(&mut self, tools: ToolRegistry) {
279 self.tools = Arc::new(RwLock::new(tools));
280 }
281
282 pub fn subagent_registry(&self) -> &Arc<Mutex<crate::runtime::subagent::SubagentRegistry>> {
283 &self.subagent_registry
284 }
285
286 pub fn event_queue(&self) -> &Arc<crate::events::EventQueue> {
287 &self.event_queue
288 }
289
290 pub fn hook_bus(&self) -> &Arc<crate::extensions::hooks::HookBus> {
292 &self.hook_bus
293 }
294
295 pub fn tools_shared(&self) -> Arc<RwLock<ToolRegistry>> {
297 Arc::clone(&self.tools)
298 }
299
300 pub fn model(&self) -> &str {
301 &self.model
302 }
303
304 pub fn http_client(&self) -> &Client {
305 &self.client
306 }
307 pub fn set_thinking_budget(&mut self, budget: u32) {
308 self.thinking_budget = budget;
309 }
310
311 pub fn set_compaction_model(&mut self, model: Option<String>) {
312 self.compaction_model = model;
313 }
314
315 pub fn set_context_window(&mut self, window: Option<u64>) {
316 self.context_window_override = window;
317 }
318
319 pub fn compaction_model(&self) -> &str {
322 self.compaction_model.as_deref().unwrap_or("claude-sonnet-4-6")
323 }
324
325 pub fn context_window(&self) -> u64 {
326 self.context_window_override
327 .unwrap_or_else(|| crate::models::context_window_for_model(&self.model))
328 }
329
330 pub fn apply_config(&mut self, config: &crate::config::SynapsConfig) {
332 if let Some(ref model) = config.model {
333 self.set_model(model.clone());
334 }
335 if let Some(budget) = config.thinking_budget {
336 self.set_thinking_budget(budget);
337 }
338 self.context_window_override = config.context_window;
339 self.compaction_model = config.compaction_model.clone();
340 self.max_tool_output = config.max_tool_output;
341 self.bash_timeout = config.bash_timeout;
342 self.bash_max_timeout = config.bash_max_timeout;
343 self.subagent_timeout = config.subagent_timeout;
344 self.api_retries = config.api_retries;
345 self.telemetry_level = crate::runtime::telemetry::TelemetryLevel::from_str_key(&config.telemetry);
346 self.cache_diagnostics = config.cache_diagnostics;
347 self.cache_ttl = config.cache_ttl;
348
349 if !config.disabled_tools.is_empty() {
353 if let Ok(mut reg) = self.tools.try_write() {
354 reg.disable(&config.disabled_tools);
355 }
356 }
357 }
358
359 pub fn thinking_budget(&self) -> u32 {
360 self.thinking_budget
361 }
362
363 pub fn max_tool_output(&self) -> usize {
364 self.max_tool_output
365 }
366
367 pub fn bash_timeout(&self) -> u64 {
368 self.bash_timeout
369 }
370
371 pub fn bash_max_timeout(&self) -> u64 {
372 self.bash_max_timeout
373 }
374
375 pub fn subagent_timeout(&self) -> u64 {
376 self.subagent_timeout
377 }
378
379 pub fn api_retries(&self) -> u32 {
380 self.api_retries
381 }
382
383 pub fn set_max_tool_output(&mut self, v: usize) {
384 self.max_tool_output = v;
385 }
386
387 pub fn set_bash_timeout(&mut self, v: u64) {
388 self.bash_timeout = v;
389 }
390
391 pub fn set_bash_max_timeout(&mut self, v: u64) {
392 self.bash_max_timeout = v;
393 }
394
395 pub fn set_subagent_timeout(&mut self, v: u64) {
396 self.subagent_timeout = v;
397 }
398
399 pub fn set_api_retries(&mut self, v: u32) {
400 self.api_retries = v;
401 }
402
403 pub fn telemetry_level(&self) -> crate::runtime::telemetry::TelemetryLevel {
404 self.telemetry_level
405 }
406
407 pub fn set_telemetry_level(&mut self, level: crate::runtime::telemetry::TelemetryLevel) {
408 self.telemetry_level = level;
409 }
410
411 pub fn cache_diagnostics(&self) -> bool {
412 self.cache_diagnostics
413 }
414
415 pub fn set_cache_diagnostics(&mut self, v: bool) {
416 self.cache_diagnostics = v;
417 }
418
419 pub fn cache_ttl(&self) -> crate::core::config::CacheTtl {
420 self.cache_ttl
421 }
422
423 pub fn set_cache_ttl(&mut self, ttl: crate::core::config::CacheTtl) {
427 self.cache_ttl = ttl;
428 }
429
430 pub fn thinking_level(&self) -> &str {
431 crate::core::models::thinking_level_for_budget(self.thinking_budget)
432 }
433
434 pub async fn refresh_if_needed(&self) -> Result<()> {
436 AuthMethods::refresh_if_needed(Arc::clone(&self.auth), &self.client).await
437 }
438
439 pub async fn compact_call(&self, messages: Vec<Value>) -> Result<String> {
445 self.refresh_if_needed().await?;
446
447 use crate::runtime::compaction::COMPACTION_SYSTEM_PROMPT;
448
449 ApiMethods::call_api_simple(
450 &self.auth,
451 &self.client,
452 self.compaction_model(),
453 COMPACTION_SYSTEM_PROMPT,
454 self.thinking_budget,
455 &messages,
456 self.api_retries,
457 ).await
458 }
459
460 pub async fn run_single(&self, prompt: &str) -> Result<String> {
463 self.refresh_if_needed().await?;
465
466 let mut messages = vec![json!({"role": "user", "content": prompt})];
467
468 loop {
469 let response = ApiMethods::call_api(
470 &self.auth,
471 &self.client,
472 &self.model,
473 &*self.tools.read().await,
474 &self.system_prompt,
475 self.thinking_budget,
476 &messages,
477 self.api_retries,
478 &api::ApiOptions {
479 use_1m_context: self.context_window_override == Some(1_000_000),
480 cache_ttl: self.cache_ttl,
481 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
482 saw_1h_honored: self.saw_1h_honored.clone(),
483 },
484 ).await?;
485
486 if let Some(content) = response["content"].as_array() {
488 let mut response_text = String::new();
489 let mut tool_uses = Vec::new();
490
491 for item in content {
493 match item["type"].as_str() {
494 Some("text") => {
495 if let Some(text) = item["text"].as_str() {
496 response_text.push_str(text);
497 }
498 }
499 Some("tool_use") => {
500 tool_uses.push(item.clone());
501 }
502 _ => {}
503 }
504 }
505
506 if tool_uses.is_empty() {
508 return Ok(response_text);
509 }
510
511 messages.push(json!({
513 "role": "assistant",
514 "content": content
515 }));
516
517 let mut tool_results = Vec::new();
519
520 if tool_uses.len() == 1 {
521 let tool_use = &tool_uses[0];
523 if let (Some(tool_name), Some(tool_id)) = (
524 tool_use["name"].as_str(),
525 tool_use["id"].as_str()
526 ) {
527 let input = &tool_use["input"];
528 let result = match self.tools.read().await.get(tool_name).cloned() {
529 Some(tool) => {
530 let input = self.tools.read().await.translate_input_for_api_tool(tool_name, input.clone());
531 let runtime_name = self.tools.read().await.runtime_name_for_api(tool_name).to_string();
532 let ctx = crate::ToolContext {
533 channels: crate::tools::ToolChannels {
534 tx_delta: None,
535 tx_events: None,
536 },
537 capabilities: crate::tools::ToolCapabilities {
538 watcher_exit_path: self.watcher_exit_path.clone(),
539 tool_register_tx: None,
540 session_manager: Some(self.session_manager.clone()),
541 subagent_registry: Some(self.subagent_registry.clone()),
542 event_queue: Some(self.event_queue.clone()),
543 secret_prompt: None,
544 },
545 limits: crate::tools::ToolLimits {
546 max_tool_output: self.max_tool_output,
547 bash_timeout: self.bash_timeout,
548 bash_max_timeout: self.bash_max_timeout,
549 subagent_timeout: self.subagent_timeout,
550 },
551 };
552 let decision = resolve_before_tool_call_decision(
553 input.clone(),
554 emit_before_tool_call(
555 &self.hook_bus,
556 tool_name,
557 Some(&runtime_name),
558 input.clone(),
559 ).await,
560 None,
561 false,
562 ).await;
563 if let BeforeToolCallDecision::Block { reason } = decision {
564 format!("Tool call blocked by extension: {}", reason)
565 } else {
566 let BeforeToolCallDecision::Continue { input } = decision else { unreachable!() };
567 let input_for_hook = input.clone();
568 let output = match tool.execute(input, ctx).await {
569 Ok(output) => output,
570 Err(e) => format!("Tool execution failed: {}", e),
571 };
572 let _ = emit_after_tool_call(
573 &self.hook_bus,
574 tool_name,
575 Some(&runtime_name),
576 input_for_hook,
577 output.clone(),
578 ).await;
579 output
580 }
581 }
582 None => format!("Unknown tool: {}", tool_name),
583 };
584 tool_results.push(json!({
585 "type": "tool_result",
586 "tool_use_id": tool_id,
587 "content": HelperMethods::truncate_tool_result(&result, self.max_tool_output)
588 }));
589 }
590 } else {
591 let mut join_set = tokio::task::JoinSet::new();
593
594 let cfg_max_tool_output = self.max_tool_output;
596 let cfg_bash_timeout = self.bash_timeout;
597 let cfg_bash_max_timeout = self.bash_max_timeout;
598 let cfg_subagent_timeout = self.subagent_timeout;
599 let session_mgr = self.session_manager.clone();
600 let cfg_subagent_registry = self.subagent_registry.clone();
601 let cfg_event_queue = self.event_queue.clone();
602 let cfg_hook_bus = self.hook_bus.clone();
603
604 for tool_use in &tool_uses {
605 if let (Some(tool_name), Some(tool_id)) = (
606 tool_use["name"].as_str().map(|s| s.to_string()),
607 tool_use["id"].as_str().map(|s| s.to_string()),
608 ) {
609 let input = tool_use["input"].clone();
610 let tools_snapshot = self.tools.read().await;
611 let input = tools_snapshot.translate_input_for_api_tool(&tool_name, input);
612 let runtime_name = tools_snapshot.runtime_name_for_api(&tool_name).to_string();
613 let tool = tools_snapshot.get(&tool_name).cloned();
614 drop(tools_snapshot);
615 let exit_path = self.watcher_exit_path.clone();
616 let session_mgr_inner = session_mgr.clone();
617 let registry_inner = cfg_subagent_registry.clone();
618 let event_queue_inner = cfg_event_queue.clone();
619 let hook_bus_inner = cfg_hook_bus.clone();
620 let tool_name_for_hook = tool_name.clone();
621 let runtime_name_for_hook = runtime_name.clone();
622
623 join_set.spawn(async move {
624 let result = match tool {
625 Some(t) => {
626 let decision = crate::runtime::resolve_before_tool_call_decision(
627 input.clone(),
628 crate::runtime::emit_before_tool_call(
629 &hook_bus_inner,
630 &tool_name_for_hook,
631 Some(&runtime_name_for_hook),
632 input.clone(),
633 ).await,
634 None,
635 false,
636 ).await;
637 if let crate::runtime::BeforeToolCallDecision::Block { reason } = decision {
638 format!("Tool call blocked by extension: {}", reason)
639 } else {
640 let crate::runtime::BeforeToolCallDecision::Continue { input } = decision else { unreachable!() };
641 let ctx = crate::ToolContext {
642 channels: crate::tools::ToolChannels {
643 tx_delta: None,
644 tx_events: None,
645 },
646 capabilities: crate::tools::ToolCapabilities {
647 watcher_exit_path: exit_path,
648 tool_register_tx: None,
649 session_manager: Some(session_mgr_inner),
650 subagent_registry: Some(registry_inner),
651 event_queue: Some(event_queue_inner),
652 secret_prompt: None,
653 },
654 limits: crate::tools::ToolLimits {
655 max_tool_output: cfg_max_tool_output,
656 bash_timeout: cfg_bash_timeout,
657 bash_max_timeout: cfg_bash_max_timeout,
658 subagent_timeout: cfg_subagent_timeout,
659 },
660 };
661 let input_for_hook = input.clone();
662 let output = match t.execute(input, ctx).await {
663 Ok(output) => output,
664 Err(e) => format!("Tool execution failed: {}", e),
665 };
666 let _ = crate::runtime::emit_after_tool_call(
667 &hook_bus_inner,
668 &tool_name_for_hook,
669 Some(&runtime_name_for_hook),
670 input_for_hook,
671 output.clone(),
672 ).await;
673 output
674 }
675 }
676 None => format!("Unknown tool: {}", tool_name),
677 };
678 (tool_id, result)
679 });
680 }
681 }
682
683 let mut results_map = std::collections::HashMap::new();
685 while let Some(res) = join_set.join_next().await {
686 match res {
687 Ok((tool_id, result)) => {
688 results_map.insert(tool_id, result);
689 }
690 Err(e) => {
691 tracing::error!("Parallel tool task panicked: {}", e);
693 }
694 }
695 }
696
697 for tool_use in &tool_uses {
699 if let Some(tool_id) = tool_use["id"].as_str() {
700 let result = results_map.remove(tool_id)
701 .unwrap_or_else(|| "Tool execution failed: task panicked".to_string());
702 tool_results.push(json!({
703 "type": "tool_result",
704 "tool_use_id": tool_id,
705 "content": HelperMethods::truncate_tool_result(&result, self.max_tool_output)
706 }));
707 }
708 }
709 }
710
711 messages.push(json!({
713 "role": "user",
714 "content": tool_results
715 }));
716
717 } else {
719 return Err(RuntimeError::Tool("Invalid response format".to_string()));
720 }
721 }
722 }
723
724 pub async fn run_stream(&self, prompt: String, cancel: CancellationToken) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
727 self.run_stream_with_messages(vec![json!({"role": "user", "content": prompt})], cancel, None, None, false).await
728 }
729
730 pub async fn run_stream_with_messages(
734 &self,
735 messages: Vec<Value>,
736 cancel: CancellationToken,
737 steering_rx: Option<mpsc::UnboundedReceiver<String>>,
738 secret_prompt: Option<crate::tools::SecretPromptHandle>,
739 auto_approve_confirms: bool,
740 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
741 let (tx, rx) = mpsc::unbounded_channel();
742
743 if let Err(e) = self.refresh_if_needed().await {
745 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(e.to_string())));
746 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
747 return Box::pin(UnboundedReceiverStream::new(rx));
748 }
749
750 let auth = Arc::clone(&self.auth);
753 let client = self.client.clone();
754 let model = self.model.clone();
755 let tools = self.tools.clone();
756 let system_prompt = self.system_prompt.clone();
757 let thinking_budget = self.thinking_budget;
758 let watcher_exit_path = self.watcher_exit_path.clone();
759 let max_tool_output = self.max_tool_output;
760 let bash_timeout = self.bash_timeout;
761 let bash_max_timeout = self.bash_max_timeout;
762 let subagent_timeout = self.subagent_timeout;
763 let api_retries = self.api_retries;
764 let session_manager = self.session_manager.clone();
765 let subagent_registry = self.subagent_registry.clone();
769 let event_queue = self.event_queue.clone();
770 let options = api::ApiOptions {
771 use_1m_context: self.context_window_override == Some(1_000_000),
772 cache_ttl: self.cache_ttl,
773 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
774 saw_1h_honored: self.saw_1h_honored.clone(),
775 };
776
777 let session = crate::runtime::stream::StreamSession {
778 auth, client, options, api_retries,
779 model, tools, system_prompt, thinking_budget,
780 tx: tx.clone(), cancel, steering_rx,
781 watcher_exit_path, max_tool_output,
782 bash_timeout, bash_max_timeout, subagent_timeout,
783 session_manager, subagent_registry, event_queue, secret_prompt,
784 hook_bus: self.hook_bus.clone(),
785 auto_approve_confirms,
786 telemetry_level: self.telemetry_level,
787 };
788
789 tokio::spawn(async move {
790 if let Err(e) = StreamMethods::run_stream_internal(session, messages).await {
791 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(e.to_string())));
792 }
793 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
794 });
795
796 Box::pin(UnboundedReceiverStream::new(rx))
797 }
798}
799
800impl Clone for Runtime {
801 fn clone(&self) -> Self {
802 Self {
803 client: self.client.clone(),
804 auth: Arc::clone(&self.auth),
805 model: self.model.clone(),
806 tools: self.tools.clone(),
807 system_prompt: self.system_prompt.clone(),
808 thinking_budget: self.thinking_budget,
809 context_window_override: self.context_window_override,
810 compaction_model: self.compaction_model.clone(),
811 subagent_registry: self.subagent_registry.clone(),
812 event_queue: self.event_queue.clone(),
813 watcher_exit_path: self.watcher_exit_path.clone(),
814 max_tool_output: self.max_tool_output,
815 bash_timeout: self.bash_timeout,
816 bash_max_timeout: self.bash_max_timeout,
817 subagent_timeout: self.subagent_timeout,
818 api_retries: self.api_retries,
819 telemetry_level: self.telemetry_level,
820 cache_diagnostics: self.cache_diagnostics,
821 cache_ttl: self.cache_ttl,
822 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
826 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
827 last_msg_id: Arc::new(Mutex::new(None)),
830 session_manager: self.session_manager.clone(),
831 hook_bus: self.hook_bus.clone(),
832 reaper_handle: None, reaper_cancel: None, }
835 }
836}
837
838#[cfg(test)]
839mod tests {
840 use super::*;
841
842 #[tokio::test]
843 async fn confirm_without_prompt_fails_closed() {
844 let result = resolve_before_tool_call_result(
845 crate::extensions::hooks::events::HookResult::Confirm {
846 message: "Run deploy?".into(),
847 },
848 None,
849 false,
850 )
851 .await;
852
853 assert!(matches!(
854 result,
855 crate::extensions::hooks::events::HookResult::Block { reason }
856 if reason.contains("requires confirmation") && reason.contains("Run deploy?")
857 ));
858 }
859
860 #[tokio::test]
861 async fn modify_result_replaces_tool_input() {
862 let result = resolve_before_tool_call_decision(
863 serde_json::json!({"command":"rm -rf /"}),
864 crate::extensions::hooks::events::HookResult::Modify {
865 input: serde_json::json!({"command":"echo safe"}),
866 },
867 None,
868 false,
869 ).await;
870
871 match result {
872 BeforeToolCallDecision::Continue { input } => {
873 assert_eq!(input, serde_json::json!({"command":"echo safe"}));
874 }
875 BeforeToolCallDecision::Block { reason } => panic!("unexpected block: {reason}"),
876 }
877 }
878
879 #[tokio::test]
880 async fn confirm_prompt_yes_continues() {
881 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
882 let handle = crate::tools::SecretPromptHandle::new(tx);
883
884 let task = tokio::spawn(async move {
885 let request = rx.recv().await.expect("confirm prompt request");
886 assert_eq!(request.title, "Confirm tool call");
887 assert!(request.prompt.contains("Run deploy?"));
888 let _ = request.response_tx.send(Some("yes".to_string()));
889 });
890
891 let result = resolve_before_tool_call_result(
892 crate::extensions::hooks::events::HookResult::Confirm {
893 message: "Run deploy?".into(),
894 },
895 Some(&handle),
896 false,
897 )
898 .await;
899
900 task.await.unwrap();
901 assert!(matches!(
902 result,
903 crate::extensions::hooks::events::HookResult::Continue
904 ));
905 }
906
907 #[tokio::test]
908 async fn confirm_prompt_non_yes_blocks() {
909 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
910 let handle = crate::tools::SecretPromptHandle::new(tx);
911
912 let task = tokio::spawn(async move {
913 let request = rx.recv().await.expect("confirm prompt request");
914 let _ = request.response_tx.send(Some("no".to_string()));
915 });
916
917 let result = resolve_before_tool_call_result(
918 crate::extensions::hooks::events::HookResult::Confirm {
919 message: "Run deploy?".into(),
920 },
921 Some(&handle),
922 false,
923 )
924 .await;
925
926 task.await.unwrap();
927 assert!(matches!(
928 result,
929 crate::extensions::hooks::events::HookResult::Block { reason }
930 if reason.contains("confirmation denied")
931 ));
932 }
933
934 #[test]
935 fn test_max_tokens_for_model() {
936 assert_eq!(HelperMethods::max_tokens_for_model("claude-opus-4-6"), 128000);
938 assert_eq!(HelperMethods::max_tokens_for_model("opus-something"), 128000);
939
940 assert_eq!(HelperMethods::max_tokens_for_model("claude-sonnet-4-20250514"), 64000);
942 assert_eq!(HelperMethods::max_tokens_for_model("haiku"), 64000);
943 assert_eq!(HelperMethods::max_tokens_for_model("claude-3-haiku"), 64000);
944 assert_eq!(HelperMethods::max_tokens_for_model("some-other-model"), 64000);
945
946 assert_eq!(HelperMethods::max_tokens_for_model(""), 64000);
948 assert_eq!(HelperMethods::max_tokens_for_model("OPUS"), 64000); assert_eq!(HelperMethods::max_tokens_for_model("model-opus-end"), 128000); }
951
952 #[test]
953 fn test_truncate_tool_result() {
954 let default_max = 30000;
955
956 let short = "This is a short string.";
958 assert_eq!(HelperMethods::truncate_tool_result(short, default_max), short);
959
960 let exact = "x".repeat(30000);
962 assert_eq!(HelperMethods::truncate_tool_result(&exact, default_max), exact);
963
964 let too_long = "x".repeat(30001);
966 let truncated = HelperMethods::truncate_tool_result(&too_long, default_max);
967
968 assert!(truncated.starts_with(&"x".repeat(30000)));
970
971 assert!(truncated.contains("[truncated — 30001 total chars, showing first 30000]"));
973
974 assert!(truncated.len() > 30000);
976
977 let very_long = "a".repeat(50000);
979 let truncated_very_long = HelperMethods::truncate_tool_result(&very_long, default_max);
980 assert!(truncated_very_long.contains("[truncated — 50000 total chars, showing first 30000]"));
981 assert!(truncated_very_long.starts_with(&"a".repeat(30000)));
982
983 let custom_truncated = HelperMethods::truncate_tool_result(&very_long, 100);
985 assert!(custom_truncated.starts_with(&"a".repeat(100)));
986 assert!(custom_truncated.contains("[truncated — 50000 total chars, showing first 100]"));
987 }
988
989 #[test]
990 fn test_thinking_level_ranges() {
991 use crate::core::models::thinking_level_for_budget;
992
993 assert_eq!(thinking_level_for_budget(0), "adaptive");
995
996 assert_eq!(thinking_level_for_budget(1), "low");
998 assert_eq!(thinking_level_for_budget(1024), "low");
999 assert_eq!(thinking_level_for_budget(2048), "low");
1000
1001 assert_eq!(thinking_level_for_budget(2049), "medium");
1003 assert_eq!(thinking_level_for_budget(3000), "medium");
1004 assert_eq!(thinking_level_for_budget(4096), "medium");
1005
1006 assert_eq!(thinking_level_for_budget(4097), "high");
1008 assert_eq!(thinking_level_for_budget(8192), "high");
1009 assert_eq!(thinking_level_for_budget(16384), "high");
1010
1011 assert_eq!(thinking_level_for_budget(16385), "xhigh");
1013 assert_eq!(thinking_level_for_budget(32768), "xhigh");
1014 assert_eq!(thinking_level_for_budget(100000), "xhigh");
1015 }
1016}