Skip to main content

agent_engine/runtime/
mod.rs

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
35/// Result of resolving before_tool_call extension policy.
36pub enum BeforeToolCallDecision {
37    Continue { input: Value },
38    Block { reason: String },
39}
40
41/// Emit a `before_tool_call` event and include the runtime tool name when it
42/// differs from the API-safe name.
43pub 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
57/// Resolve a before_tool_call result that may request user confirmation.
58///
59/// When `auto_approve_confirms` is true, `Confirm` is short-circuited to `Continue`.
60/// Headless/non-interactive callers with `auto_approve_confirms = false` fail closed.
61pub 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
102/// Resolve before_tool_call policy into executable input or a block reason.
103pub 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
120/// Emit an `after_tool_call` event and include the runtime tool name when it
121/// differs from the API-safe name.
122pub 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
140/// The core runtime — manages API communication, tool execution, authentication,
141/// and streaming for all SynapsCLI binaries (chat, chatui, server, agent, watcher).
142pub 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    /// User override for context window size (tokens). When set, takes
150    /// precedence over the model's auto-detected window from
151    /// `models::context_window_for_model`. Lets users cap context at e.g.
152    /// 200k even on models that natively support 1M.
153    context_window_override: Option<u64>,
154    /// Model used for compaction. Falls back to claude-sonnet-4-6 if not set.
155    compaction_model: Option<String>,
156    /// Shared registry for reactive subagent handles.
157    subagent_registry: Arc<Mutex<crate::runtime::subagent::SubagentRegistry>>,
158    /// Shared event queue — for Event Bus tooling.
159    event_queue: Arc<crate::events::EventQueue>,
160    /// Path for watcher_exit tool to write handoff state (agent mode only)
161    pub watcher_exit_path: Option<PathBuf>,
162    // New configurable fields
163    max_tool_output: usize,
164    bash_timeout: u64,
165    bash_max_timeout: u64,
166    subagent_timeout: u64,
167    api_retries: u32,
168    /// Telemetry level for structured per-request API logging (opt-in).
169    telemetry_level: crate::runtime::telemetry::TelemetryLevel,
170    /// Opt into the cache-diagnosis beta (`cache-diagnosis-2026-04-07`).
171    cache_diagnostics: bool,
172    /// Prompt-cache TTL strategy (5m default | 1h | hybrid). Threaded into
173    /// every request via `ApiOptions`.
174    cache_ttl: crate::core::config::CacheTtl,
175    /// One-time-per-session latch for the silent 1h-downgrade notice
176    /// (spec §3.4.1). Shared into `ApiOptions` for every request.
177    ttl_downgrade_notified: std::sync::Arc<std::sync::atomic::AtomicBool>,
178    /// Session-scoped "1h honored at least once" latch (spec §3.4.1) —
179    /// suppresses the downgrade notice on healthy Hybrid turns where the 1h
180    /// prefix is already cached. Shared into `ApiOptions` for every request.
181    saw_1h_honored: std::sync::Arc<std::sync::atomic::AtomicBool>,
182    /// Last Anthropic message id (`msg_...`) — threaded into the next
183    /// request's `diagnostics.previous_message_id` when diagnostics is on.
184    /// Reserved for the cache-diagnosis beta wiring (handoff item).
185    #[allow(dead_code)]
186    last_msg_id: Arc<Mutex<Option<String>>>,
187    session_manager: std::sync::Arc<crate::tools::shell::SessionManager>,
188    /// Extension hook bus for dispatching events to extensions.
189    hook_bus: Arc<crate::extensions::hooks::HookBus>,
190    // Held to keep the reaper task alive for the Runtime's lifetime; never read directly.
191    #[allow(dead_code)]
192    reaper_handle: Option<tokio::task::JoinHandle<()>>,
193    #[allow(dead_code)]
194    reaper_cancel: Option<tokio_util::sync::CancellationToken>,
195    /// How provider credentials are resolved: `Local` (read/refresh auth.json —
196    /// the default) or `Remote` (fetch short-lived access tokens from a broker).
197    /// Set from config in `apply_config`. See task #157.
198    credential_source: crate::auth::CredentialSource,
199    /// In-memory cache of broker-fetched access tokens (Remote source only).
200    /// Cheap to clone (Arc inside). Never persisted to disk.
201    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        // Start the idle session reaper
222        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        // Strip any health/status prefix (e.g. "✅  339ms  groq/..." → "groq/...")
273        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    /// Get a shared reference to the extension hook bus.
300    pub fn hook_bus(&self) -> &Arc<crate::extensions::hooks::HookBus> {
301        &self.hook_bus
302    }
303
304    /// Get a shared reference to the tool registry (for MCP lazy loading).
305    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    /// Effective context window for the current model — user override if set,
329    /// otherwise the model's native window from `models::context_window_for_model`.
330    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    /// Apply a parsed config file to this runtime (model, thinking budget, etc.)
340    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        // Remove any built-in tools the user disabled via `disabled_tools`.
360        // try_write is safe here: apply_config runs at boot before the registry
361        // is shared with other tasks.
362        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    /// Apply only the credential-source portion of config. Used by subagent
370    /// spawns that build a fresh `Runtime::new()` (which defaults to Local) and
371    /// must inherit the user's Remote/broker setup. (#158 A3)
372    ///
373    /// A6: invalidates the token cache if the source/endpoint changed.
374    /// A2: scrubs `AuthState` when Remote so no local `auth.json` credential is
375    /// used or held (invariant 1).
376    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    /// Change the cache TTL strategy mid-session. The next request re-marks
454    /// with the new TTL; the old prefix expires naturally (single-last
455    /// strategy never prunes old markers, so no invalidation logic needed).
456    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    /// Check if the OAuth token is expired and refresh it if needed.
465    pub async fn refresh_if_needed(&self) -> Result<()> {
466        // Non-Anthropic models resolve their own provider auth in the OpenAI
467        // path (incl. via the broker), so skip the Anthropic pre-fetch. (#158 #7)
468        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    /// Make a simple non-streaming API call for compaction (no tools).
481    ///
482    /// Uses a dedicated summarization system prompt (not the user's), omits
483    /// all tools, and returns the raw text response. Caller supplies the
484    /// full message array including the serialized conversation.
485    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    /// Run a single prompt synchronously (non-streaming). Handles tool execution
502    /// internally, looping until the model produces a final text response.
503    pub async fn run_single(&self, prompt: &str) -> Result<String> {
504        // Refresh OAuth token if expired
505        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            // Check if Claude wants to use tools
530            if let Some(content) = response["content"].as_array() {
531                let mut response_text = String::new();
532                let mut tool_uses = Vec::new();
533                
534                // Process response content
535                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 no tool uses, return the text response
550                if tool_uses.is_empty() {
551                    return Ok(response_text);
552                }
553                
554                // Add assistant's response to conversation (only content, role)
555                messages.push(json!({
556                    "role": "assistant",
557                    "content": content
558                }));
559                
560                // Execute tools — parallel when multiple are requested
561                let mut tool_results = Vec::new();
562                
563                if tool_uses.len() == 1 {
564                    // Single tool — run inline, no spawn overhead
565                    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                    // Multiple tools — run in parallel with JoinSet
635                    let mut join_set = tokio::task::JoinSet::new();
636                    
637                    // Capture config values before spawning (can't borrow &self in 'static spawn)
638                    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                    // Collect results, preserving order by tool_id
727                    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                                // Task panicked — log it but don't crash
735                                tracing::error!("Parallel tool task panicked: {}", e);
736                            }
737                        }
738                    }
739                    
740                    // Build tool_results in original order — every tool_use MUST have a result
741                    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                // Add tool results to conversation
755                messages.push(json!({
756                    "role": "user",
757                    "content": tool_results
758                }));
759                
760                // Continue the loop to get Claude's response with tool results
761            } else {
762                return Err(RuntimeError::Tool("Invalid response format".to_string()));
763            }
764        }
765    }
766
767    /// Run a prompt as a cancellable stream of [`StreamEvent`]s. Convenience wrapper
768    /// around [`run_stream_with_messages`] for single-turn usage.
769    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    /// Run a multi-turn conversation as a cancellable stream of [`StreamEvent`]s.
774    /// This is the main entry point for chat UIs and agents. Handles tool execution,
775    /// API retries, and dynamic tool registration (MCP) internally.
776    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        // Refresh OAuth token if expired before starting the stream.
787        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        // Clone the Arc, not the whole Runtime — the spawned task shares the
794        // same AuthState so mid-loop token refreshes are visible immediately.
795        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        // Opt into the 1M-context beta header only when the user explicitly
811        // requested 1M (via context_window setting). Default 200k matches
812        // Anthropic's claude-code default and gives smarter inference.
813        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            // Subagents are their own session — fresh latches so a downgrade
870            // in the subagent's chain surfaces its own (single) notice and
871            // honored-state isn't inherited from the parent.
872            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            // Subagents start their own request chain — inheriting the parent's
875            // last msg_id would produce bogus `messages_changed` diagnostics.
876            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,  // Cloned runtimes don't own the reaper
880            reaper_cancel: None,  // Cloned runtimes don't own the reaper
881            credential_source: self.credential_source.clone(),
882            token_cache: self.token_cache.clone(), // shares the same cache (Arc inside)
883        }
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        // Opus models should return 128000
986        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        // Non-opus models should return 64000
990        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        // Edge cases
996        assert_eq!(HelperMethods::max_tokens_for_model(""), 64000);
997        assert_eq!(HelperMethods::max_tokens_for_model("OPUS"), 64000); // Case sensitive - uppercase doesn't match
998        assert_eq!(HelperMethods::max_tokens_for_model("model-opus-end"), 128000); // Contains "opus" anywhere
999    }
1000
1001    #[test]
1002    fn test_truncate_tool_result() {
1003        let default_max = 30000;
1004        
1005        // Short string should remain unchanged
1006        let short = "This is a short string.";
1007        assert_eq!(HelperMethods::truncate_tool_result(short, default_max), short);
1008        
1009        // Exactly max should remain unchanged
1010        let exact = "x".repeat(30000);
1011        assert_eq!(HelperMethods::truncate_tool_result(&exact, default_max), exact);
1012        
1013        // String longer than max should be truncated with notice
1014        let too_long = "x".repeat(30001);
1015        let truncated = HelperMethods::truncate_tool_result(&too_long, default_max);
1016        
1017        // Should start with the truncated content
1018        assert!(truncated.starts_with(&"x".repeat(30000)));
1019        
1020        // Should contain truncation notice with total char count
1021        assert!(truncated.contains("[truncated — 30001 total chars, showing first 30000]"));
1022        
1023        // Should be longer than max (due to notice)
1024        assert!(truncated.len() > 30000);
1025        
1026        // Test with a much longer string
1027        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        // Test with custom limit
1033        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        // Sentinel 0 = "adaptive" (S172 — model decides)
1043        assert_eq!(thinking_level_for_budget(0), "adaptive");
1044
1045        // Low range: 1..=2048
1046        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        // Medium range: 2049..=4096
1051        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        // High range: 4097..=16384
1056        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        // XHigh range: _ (everything else)
1061        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}