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