Skip to main content

agent_engine/runtime/
mod.rs

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
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/// Resolve a before_tool_call result that may request user confirmation.
57///
58/// When `auto_approve_confirms` is true, `Confirm` is short-circuited to `Continue`.
59/// Headless/non-interactive callers with `auto_approve_confirms = false` fail closed.
60pub 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
103/// Resolve before_tool_call policy into executable input or a block reason.
104pub 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
123/// Maximum size (bytes) of a `Replace` output an extension may substitute.
124/// A transform returning more than this is clamped (with a warning) to bound
125/// memory between deserialization and the downstream tool-output truncation.
126/// Generous on purpose — legitimate compression/redaction shrinks output.
127const MAX_REPLACE_OUTPUT: usize = 1024 * 1024; // 1 MiB
128
129/// Emit an `after_tool_call` event and return the tool output to record in
130/// history — either the original `output`, or a `Replace { output }`
131/// substituted by an extension transform hook (compression, redaction,
132/// summarization). The runtime tool name is included when it differs from the
133/// API-safe name.
134///
135/// Fail-safe: any non-`Replace` result — `Continue`, a result rejected by the
136/// permission matrix, a crashed/timed-out handler, or any future variant —
137/// returns the original `output` unchanged. A misbehaving extension can never
138/// drop or corrupt a tool's output.
139///
140/// Note: the event delivered to the extension carries `tool_output` as a
141/// size-limited preview for large outputs — a ~256 KB prefix plus a
142/// `…[truncated, N total bytes]` marker (see [`HookEvent::after_tool_call`]),
143/// matching `max_tool_buffer`. The final returned string is then truncated to
144/// `max_tool_output` (the context budget) — compress-then-truncate ordering,
145/// so a transform extension sees the full buffered output and decides what to
146/// keep before the hard cap is applied.
147pub 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    // Keep the original to return verbatim if no transform fires.
157    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                // Floor to a UTF-8 char boundary before truncating.
173                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    // Compress-then-truncate: apply the context-budget cap AFTER the hook.
192    // Mirrors `HelperMethods::truncate_tool_result` byte-for-byte so the
193    // no-extension path is behavior-identical to the legacy ordering.
194    crate::runtime::helpers::HelperMethods::truncate_tool_result(&post_hook, max_tool_output)
195}
196
197/// The core runtime — manages API communication, tool execution, authentication,
198/// and streaming for all SynapsCLI binaries (chat, chatui, server, agent, watcher).
199pub 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    /// User override for context window size (tokens). When set, takes
207    /// precedence over the model's auto-detected window from
208    /// `models::context_window_for_model`. Lets users cap context at e.g.
209    /// 200k even on models that natively support 1M.
210    context_window_override: Option<u64>,
211    /// Model used for compaction. Falls back to claude-sonnet-4-6 if not set.
212    compaction_model: Option<String>,
213    /// Shared registry for reactive subagent handles.
214    subagent_registry: Arc<Mutex<crate::runtime::subagent::SubagentRegistry>>,
215    /// Shared event queue — for Event Bus tooling.
216    event_queue: Arc<crate::events::EventQueue>,
217    /// Path for watcher_exit tool to write handoff state (agent mode only)
218    pub watcher_exit_path: Option<PathBuf>,
219    // New configurable fields
220    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 for structured per-request API logging (opt-in).
227    telemetry_level: crate::runtime::telemetry::TelemetryLevel,
228    /// Opt into the cache-diagnosis beta (`cache-diagnosis-2026-04-07`).
229    cache_diagnostics: bool,
230    /// Prompt-cache TTL strategy (5m default | 1h | hybrid). Threaded into
231    /// every request via `ApiOptions`.
232    cache_ttl: crate::core::config::CacheTtl,
233    /// One-time-per-session latch for the silent 1h-downgrade notice
234    /// (spec §3.4.1). Shared into `ApiOptions` for every request.
235    ttl_downgrade_notified: std::sync::Arc<std::sync::atomic::AtomicBool>,
236    /// Session-scoped "1h honored at least once" latch (spec §3.4.1) —
237    /// suppresses the downgrade notice on healthy Hybrid turns where the 1h
238    /// prefix is already cached. Shared into `ApiOptions` for every request.
239    saw_1h_honored: std::sync::Arc<std::sync::atomic::AtomicBool>,
240    /// Last Anthropic message id (`msg_...`) — threaded into the next
241    /// request's `diagnostics.previous_message_id` when diagnostics is on.
242    /// Reserved for the cache-diagnosis beta wiring (handoff item).
243    #[allow(dead_code)]
244    last_msg_id: Arc<Mutex<Option<String>>>,
245    session_manager: std::sync::Arc<crate::tools::shell::SessionManager>,
246    /// Extension hook bus for dispatching events to extensions.
247    hook_bus: Arc<crate::extensions::hooks::HookBus>,
248    // Held to keep the reaper task alive for the Runtime's lifetime; never read directly.
249    #[allow(dead_code)]
250    reaper_handle: Option<tokio::task::JoinHandle<()>>,
251    #[allow(dead_code)]
252    reaper_cancel: Option<tokio_util::sync::CancellationToken>,
253    /// How provider credentials are resolved: `Local` (read/refresh auth.json —
254    /// the default) or `Remote` (fetch short-lived access tokens from a broker).
255    /// Set from config in `apply_config`. See task #157.
256    credential_source: crate::auth::CredentialSource,
257    /// In-memory cache of broker-fetched access tokens (Remote source only).
258    /// Cheap to clone (Arc inside). Never persisted to disk.
259    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        // Start the idle session reaper
280        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        // Strip any health/status prefix (e.g. "✅  339ms  groq/..." → "groq/...")
334        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    /// Get a shared reference to the extension hook bus.
368    pub fn hook_bus(&self) -> &Arc<crate::extensions::hooks::HookBus> {
369        &self.hook_bus
370    }
371
372    /// Get a shared reference to the tool registry (for MCP lazy loading).
373    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    /// Effective context window for the current model — user override if set,
397    /// otherwise the model's native window from `models::context_window_for_model`.
398    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    /// Apply a parsed config file to this runtime (model, thinking budget, etc.)
410    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        // Remove any built-in tools the user disabled via `disabled_tools`.
432        // try_write is safe here: apply_config runs at boot before the registry
433        // is shared with other tasks.
434        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    /// Apply only the credential-source portion of config. Used by subagent
442    /// spawns that build a fresh `Runtime::new()` (which defaults to Local) and
443    /// must inherit the user's Remote/broker setup. (#158 A3)
444    ///
445    /// A6: invalidates the token cache if the source/endpoint changed.
446    /// A2: scrubs `AuthState` when Remote so no local `auth.json` credential is
447    /// used or held (invariant 1).
448    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    /// Change the cache TTL strategy mid-session. The next request re-marks
526    /// with the new TTL; the old prefix expires naturally (single-last
527    /// strategy never prunes old markers, so no invalidation logic needed).
528    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    /// Check if the OAuth token is expired and refresh it if needed.
537    pub async fn refresh_if_needed(&self) -> Result<()> {
538        // Non-Anthropic models resolve their own provider auth in the OpenAI
539        // path (incl. via the broker), so skip the Anthropic pre-fetch. (#158 #7)
540        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    /// Make a simple non-streaming API call for compaction (no tools).
553    ///
554    /// Uses a dedicated summarization system prompt (not the user's), omits
555    /// all tools, and returns the raw text response. Caller supplies the
556    /// full message array including the serialized conversation.
557    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    /// Run a single prompt synchronously (non-streaming). Handles tool execution
575    /// internally, looping until the model produces a final text response.
576    pub async fn run_single(&self, prompt: &str) -> Result<String> {
577        // Refresh OAuth token if expired
578        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            // Check if Claude wants to use tools
605            if let Some(content) = response["content"].as_array() {
606                let mut response_text = String::new();
607                let mut tool_uses = Vec::new();
608
609                // Process response content
610                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 no tool uses, return the text response
625                if tool_uses.is_empty() {
626                    return Ok(response_text);
627                }
628
629                // Add assistant's response to conversation (only content, role)
630                messages.push(json!({
631                    "role": "assistant",
632                    "content": content
633                }));
634
635                // Execute tools — parallel when multiple are requested
636                let mut tool_results = Vec::new();
637
638                if tool_uses.len() == 1 {
639                    // Single tool — run inline, no spawn overhead
640                    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                    // Multiple tools — run in parallel with JoinSet
726                    let mut join_set = tokio::task::JoinSet::new();
727
728                    // Capture config values before spawning (can't borrow &self in 'static spawn)
729                    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                    // Collect results, preserving order by tool_id
834                    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                                // Task panicked — log it but don't crash
842                                tracing::error!("Parallel tool task panicked: {}", e);
843                            }
844                        }
845                    }
846
847                    // Build tool_results in original order — every tool_use MUST have a result
848                    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                // Add tool results to conversation
863                messages.push(json!({
864                    "role": "user",
865                    "content": tool_results
866                }));
867
868                // Continue the loop to get Claude's response with tool results
869            } else {
870                return Err(RuntimeError::Tool("Invalid response format".to_string()));
871            }
872        }
873    }
874
875    /// Run a prompt as a cancellable stream of [`StreamEvent`]s. Convenience wrapper
876    /// around [`run_stream_with_messages`] for single-turn usage.
877    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    /// Run a multi-turn conversation as a cancellable stream of [`StreamEvent`]s.
893    /// This is the main entry point for chat UIs and agents. Handles tool execution,
894    /// API retries, and dynamic tool registration (MCP) internally.
895    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        // Refresh OAuth token if expired before starting the stream.
906        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        // Clone the Arc, not the whole Runtime — the spawned task shares the
913        // same AuthState so mid-loop token refreshes are visible immediately.
914        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        // Opt into the 1M-context beta header only when the user explicitly
931        // requested 1M (via context_window setting). Default 200k matches
932        // Anthropic's claude-code default and gives smarter inference.
933        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            // Subagents are their own session — fresh latches so a downgrade
1009            // in the subagent's chain surfaces its own (single) notice and
1010            // honored-state isn't inherited from the parent.
1011            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            // Subagents start their own request chain — inheriting the parent's
1014            // last msg_id would produce bogus `messages_changed` diagnostics.
1015            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, // Cloned runtimes don't own the reaper
1019            reaper_cancel: None, // Cloned runtimes don't own the reaper
1020            credential_source: self.credential_source.clone(),
1021            token_cache: self.token_cache.clone(), // shares the same cache (Arc inside)
1022        }
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        // Opus models should return 128000
1126        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        // Non-opus models should return 64000
1136        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        // Edge cases
1148        assert_eq!(HelperMethods::max_tokens_for_model(""), 64000);
1149        assert_eq!(HelperMethods::max_tokens_for_model("OPUS"), 64000); // Case sensitive - uppercase doesn't match
1150        assert_eq!(
1151            HelperMethods::max_tokens_for_model("model-opus-end"),
1152            128000
1153        ); // Contains "opus" anywhere
1154    }
1155
1156    #[test]
1157    fn test_truncate_tool_result() {
1158        let default_max = 30000;
1159
1160        // Short string should remain unchanged
1161        let short = "This is a short string.";
1162        assert_eq!(
1163            HelperMethods::truncate_tool_result(short, default_max),
1164            short
1165        );
1166
1167        // Exactly max should remain unchanged
1168        let exact = "x".repeat(30000);
1169        assert_eq!(
1170            HelperMethods::truncate_tool_result(&exact, default_max),
1171            exact
1172        );
1173
1174        // String longer than max should be truncated with notice
1175        let too_long = "x".repeat(30001);
1176        let truncated = HelperMethods::truncate_tool_result(&too_long, default_max);
1177
1178        // Should start with the truncated content
1179        assert!(truncated.starts_with(&"x".repeat(30000)));
1180
1181        // Should contain truncation notice with total char count
1182        assert!(truncated.contains("[truncated — 30001 total chars, showing first 30000]"));
1183
1184        // Should be longer than max (due to notice)
1185        assert!(truncated.len() > 30000);
1186
1187        // Test with a much longer string
1188        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        // Test with custom limit
1196        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        // Sentinel 0 = "adaptive" (S172 — model decides)
1206        assert_eq!(thinking_level_for_budget(0), "adaptive");
1207
1208        // Low range: 1..=2048
1209        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        // Medium range: 2049..=4096
1214        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        // High range: 4097..=16384
1219        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        // XHigh range: _ (everything else)
1224        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}