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    /// Telemetry level for structured per-request API logging (opt-in).
226    telemetry_level: crate::runtime::telemetry::TelemetryLevel,
227    /// Opt into the cache-diagnosis beta (`cache-diagnosis-2026-04-07`).
228    cache_diagnostics: bool,
229    /// Prompt-cache TTL strategy (5m default | 1h | hybrid). Threaded into
230    /// every request via `ApiOptions`.
231    cache_ttl: crate::core::config::CacheTtl,
232    /// One-time-per-session latch for the silent 1h-downgrade notice
233    /// (spec §3.4.1). Shared into `ApiOptions` for every request.
234    ttl_downgrade_notified: std::sync::Arc<std::sync::atomic::AtomicBool>,
235    /// Session-scoped "1h honored at least once" latch (spec §3.4.1) —
236    /// suppresses the downgrade notice on healthy Hybrid turns where the 1h
237    /// prefix is already cached. Shared into `ApiOptions` for every request.
238    saw_1h_honored: std::sync::Arc<std::sync::atomic::AtomicBool>,
239    /// Last Anthropic message id (`msg_...`) — threaded into the next
240    /// request's `diagnostics.previous_message_id` when diagnostics is on.
241    /// Reserved for the cache-diagnosis beta wiring (handoff item).
242    #[allow(dead_code)]
243    last_msg_id: Arc<Mutex<Option<String>>>,
244    session_manager: std::sync::Arc<crate::tools::shell::SessionManager>,
245    /// Extension hook bus for dispatching events to extensions.
246    hook_bus: Arc<crate::extensions::hooks::HookBus>,
247    // Held to keep the reaper task alive for the Runtime's lifetime; never read directly.
248    #[allow(dead_code)]
249    reaper_handle: Option<tokio::task::JoinHandle<()>>,
250    #[allow(dead_code)]
251    reaper_cancel: Option<tokio_util::sync::CancellationToken>,
252    /// How provider credentials are resolved: `Local` (read/refresh auth.json —
253    /// the default) or `Remote` (fetch short-lived access tokens from a broker).
254    /// Set from config in `apply_config`. See task #157.
255    credential_source: crate::auth::CredentialSource,
256    /// In-memory cache of broker-fetched access tokens (Remote source only).
257    /// Cheap to clone (Arc inside). Never persisted to disk.
258    token_cache: crate::auth::TokenCache,
259}
260
261impl Runtime {
262    pub async fn new() -> Result<Self> {
263        let (auth_token, auth_type, refresh_token, token_expires) = AuthMethods::get_auth_token()?;
264
265        let client = Client::builder()
266            .tls_built_in_webpki_certs(true)
267            .tls_built_in_native_certs(true)
268            .connect_timeout(Duration::from_secs(10))
269            .timeout(Duration::from_secs(300))
270            .build()
271            .map_err(|e| RuntimeError::Config(format!("Failed to build HTTP client: {}", e)))?;
272
273        let session_manager = {
274            let config = crate::tools::shell::ShellConfig::default();
275            crate::tools::shell::SessionManager::new(config)
276        };
277
278        // Start the idle session reaper
279        let mgr = session_manager.clone();
280        let cancel = tokio_util::sync::CancellationToken::new();
281        let reaper_handle = crate::tools::shell::session::start_reaper(mgr, cancel.clone());
282
283        Ok(Runtime {
284            client,
285            auth: Arc::new(RwLock::new(AuthState {
286                auth_token,
287                auth_type,
288                refresh_token,
289                token_expires,
290            })),
291            model: crate::models::default_model().to_string(),
292            tools: Arc::new(RwLock::new(ToolRegistry::new())),
293            system_prompt: None,
294            thinking_budget: 4096,
295            context_window_override: None,
296            compaction_model: None,
297            subagent_registry: Arc::new(Mutex::new(
298                crate::runtime::subagent::SubagentRegistry::new(),
299            )),
300            event_queue: Arc::new(crate::events::EventQueue::new(1000)),
301            watcher_exit_path: None,
302            max_tool_output: 30000,
303            bash_timeout: 30,
304            bash_max_timeout: 300,
305            subagent_timeout: 300,
306            api_retries: 3,
307            telemetry_level: crate::runtime::telemetry::TelemetryLevel::Off,
308            cache_diagnostics: false,
309            cache_ttl: crate::core::config::CacheTtl::default(),
310            ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
311            saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
312            last_msg_id: Arc::new(Mutex::new(None)),
313            session_manager,
314            hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
315            reaper_handle: Some(reaper_handle),
316            reaper_cancel: Some(cancel),
317            credential_source: crate::auth::CredentialSource::Local,
318            token_cache: crate::auth::TokenCache::new(),
319        })
320    }
321
322    pub fn set_system_prompt(&mut self, prompt: String) {
323        self.system_prompt = Some(prompt);
324    }
325
326    pub fn system_prompt(&self) -> Option<&str> {
327        self.system_prompt.as_deref()
328    }
329
330    pub fn set_model(&mut self, model: String) {
331        // Strip any health/status prefix (e.g. "✅  339ms  groq/..." → "groq/...")
332        let cleaned = if let Some(pos) = model.find("claude-") {
333            model[pos..].to_string()
334        } else if let Some(pos) = model.find('/') {
335            let before = &model[..pos];
336            let key_start = before
337                .rfind(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
338                .map(|i| {
339                    i + before[i..]
340                        .chars()
341                        .next()
342                        .map(|c| c.len_utf8())
343                        .unwrap_or(1)
344                })
345                .unwrap_or(0);
346            model[key_start..].to_string()
347        } else {
348            model
349        };
350        self.model = cleaned;
351    }
352
353    pub fn set_tools(&mut self, tools: ToolRegistry) {
354        self.tools = Arc::new(RwLock::new(tools));
355    }
356
357    pub fn subagent_registry(&self) -> &Arc<Mutex<crate::runtime::subagent::SubagentRegistry>> {
358        &self.subagent_registry
359    }
360
361    pub fn event_queue(&self) -> &Arc<crate::events::EventQueue> {
362        &self.event_queue
363    }
364
365    /// Get a shared reference to the extension hook bus.
366    pub fn hook_bus(&self) -> &Arc<crate::extensions::hooks::HookBus> {
367        &self.hook_bus
368    }
369
370    /// Get a shared reference to the tool registry (for MCP lazy loading).
371    pub fn tools_shared(&self) -> Arc<RwLock<ToolRegistry>> {
372        Arc::clone(&self.tools)
373    }
374
375    pub fn model(&self) -> &str {
376        &self.model
377    }
378
379    pub fn http_client(&self) -> &Client {
380        &self.client
381    }
382    pub fn set_thinking_budget(&mut self, budget: u32) {
383        self.thinking_budget = budget;
384    }
385
386    pub fn set_compaction_model(&mut self, model: Option<String>) {
387        self.compaction_model = model;
388    }
389
390    pub fn set_context_window(&mut self, window: Option<u64>) {
391        self.context_window_override = window;
392    }
393
394    /// Effective context window for the current model — user override if set,
395    /// otherwise the model's native window from `models::context_window_for_model`.
396    pub fn compaction_model(&self) -> &str {
397        self.compaction_model
398            .as_deref()
399            .unwrap_or("claude-sonnet-4-6")
400    }
401
402    pub fn context_window(&self) -> u64 {
403        self.context_window_override
404            .unwrap_or_else(|| crate::models::context_window_for_model(&self.model))
405    }
406
407    /// Apply a parsed config file to this runtime (model, thinking budget, etc.)
408    pub fn apply_config(&mut self, config: &crate::config::SynapsConfig) {
409        if let Some(ref model) = config.model {
410            self.set_model(model.clone());
411        }
412        if let Some(budget) = config.thinking_budget {
413            self.set_thinking_budget(budget);
414        }
415        self.context_window_override = config.context_window;
416        self.compaction_model = config.compaction_model.clone();
417        self.max_tool_output = config.max_tool_output;
418        self.bash_timeout = config.bash_timeout;
419        self.bash_max_timeout = config.bash_max_timeout;
420        self.subagent_timeout = config.subagent_timeout;
421        self.api_retries = config.api_retries;
422        self.telemetry_level =
423            crate::runtime::telemetry::TelemetryLevel::from_str_key(&config.telemetry);
424        self.cache_diagnostics = config.cache_diagnostics;
425        self.cache_ttl = config.cache_ttl;
426        self.apply_auth_config(config);
427
428        // Remove any built-in tools the user disabled via `disabled_tools`.
429        // try_write is safe here: apply_config runs at boot before the registry
430        // is shared with other tasks.
431        if !config.disabled_tools.is_empty() {
432            if let Ok(mut reg) = self.tools.try_write() {
433                reg.disable(&config.disabled_tools);
434            }
435        }
436    }
437
438    /// Apply only the credential-source portion of config. Used by subagent
439    /// spawns that build a fresh `Runtime::new()` (which defaults to Local) and
440    /// must inherit the user's Remote/broker setup. (#158 A3)
441    ///
442    /// A6: invalidates the token cache if the source/endpoint changed.
443    /// A2: scrubs `AuthState` when Remote so no local `auth.json` credential is
444    /// used or held (invariant 1).
445    pub fn apply_auth_config(&mut self, config: &crate::config::SynapsConfig) {
446        let new_source = config.auth.credential_source();
447        if new_source != self.credential_source {
448            self.token_cache.invalidate("anthropic");
449        }
450        self.credential_source = new_source;
451        if self.credential_source.is_remote() {
452            if let Ok(mut auth) = self.auth.try_write() {
453                AuthMethods::scrub_for_remote(&mut auth);
454            }
455        }
456    }
457
458    pub fn thinking_budget(&self) -> u32 {
459        self.thinking_budget
460    }
461
462    pub fn max_tool_output(&self) -> usize {
463        self.max_tool_output
464    }
465
466    pub fn bash_timeout(&self) -> u64 {
467        self.bash_timeout
468    }
469
470    pub fn bash_max_timeout(&self) -> u64 {
471        self.bash_max_timeout
472    }
473
474    pub fn subagent_timeout(&self) -> u64 {
475        self.subagent_timeout
476    }
477
478    pub fn api_retries(&self) -> u32 {
479        self.api_retries
480    }
481
482    pub fn set_max_tool_output(&mut self, v: usize) {
483        self.max_tool_output = v;
484    }
485
486    pub fn set_bash_timeout(&mut self, v: u64) {
487        self.bash_timeout = v;
488    }
489
490    pub fn set_bash_max_timeout(&mut self, v: u64) {
491        self.bash_max_timeout = v;
492    }
493
494    pub fn set_subagent_timeout(&mut self, v: u64) {
495        self.subagent_timeout = v;
496    }
497
498    pub fn set_api_retries(&mut self, v: u32) {
499        self.api_retries = v;
500    }
501
502    pub fn telemetry_level(&self) -> crate::runtime::telemetry::TelemetryLevel {
503        self.telemetry_level
504    }
505
506    pub fn set_telemetry_level(&mut self, level: crate::runtime::telemetry::TelemetryLevel) {
507        self.telemetry_level = level;
508    }
509
510    pub fn cache_diagnostics(&self) -> bool {
511        self.cache_diagnostics
512    }
513
514    pub fn set_cache_diagnostics(&mut self, v: bool) {
515        self.cache_diagnostics = v;
516    }
517
518    pub fn cache_ttl(&self) -> crate::core::config::CacheTtl {
519        self.cache_ttl
520    }
521
522    /// Change the cache TTL strategy mid-session. The next request re-marks
523    /// with the new TTL; the old prefix expires naturally (single-last
524    /// strategy never prunes old markers, so no invalidation logic needed).
525    pub fn set_cache_ttl(&mut self, ttl: crate::core::config::CacheTtl) {
526        self.cache_ttl = ttl;
527    }
528
529    pub fn thinking_level(&self) -> &str {
530        crate::core::models::thinking_level_for_budget(self.thinking_budget)
531    }
532
533    /// Check if the OAuth token is expired and refresh it if needed.
534    pub async fn refresh_if_needed(&self) -> Result<()> {
535        // Non-Anthropic models resolve their own provider auth in the OpenAI
536        // path (incl. via the broker), so skip the Anthropic pre-fetch. (#158 #7)
537        if !crate::runtime::auth::model_is_anthropic(&self.model) {
538            return Ok(());
539        }
540        AuthMethods::refresh_if_needed(
541            Arc::clone(&self.auth),
542            &self.client,
543            &self.credential_source,
544            &self.token_cache,
545        )
546        .await
547    }
548
549    /// Make a simple non-streaming API call for compaction (no tools).
550    ///
551    /// Uses a dedicated summarization system prompt (not the user's), omits
552    /// all tools, and returns the raw text response. Caller supplies the
553    /// full message array including the serialized conversation.
554    pub async fn compact_call(&self, messages: Vec<Value>) -> Result<String> {
555        self.refresh_if_needed().await?;
556
557        use crate::runtime::compaction::COMPACTION_SYSTEM_PROMPT;
558
559        ApiMethods::call_api_simple(
560            &self.auth,
561            &self.client,
562            self.compaction_model(),
563            COMPACTION_SYSTEM_PROMPT,
564            self.thinking_budget,
565            &messages,
566            self.api_retries,
567        )
568        .await
569    }
570
571    /// Run a single prompt synchronously (non-streaming). Handles tool execution
572    /// internally, looping until the model produces a final text response.
573    pub async fn run_single(&self, prompt: &str) -> Result<String> {
574        // Refresh OAuth token if expired
575        self.refresh_if_needed().await?;
576
577        let mut messages = vec![json!({"role": "user", "content": prompt})];
578
579        loop {
580            let response = ApiMethods::call_api(
581                &self.auth,
582                &self.client,
583                &self.model,
584                &*self.tools.read().await,
585                &self.system_prompt,
586                self.thinking_budget,
587                &messages,
588                self.api_retries,
589                &api::ApiOptions {
590                    use_1m_context: self.context_window_override == Some(1_000_000),
591                    cache_ttl: self.cache_ttl,
592                    ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
593                    saw_1h_honored: self.saw_1h_honored.clone(),
594                    credential_source: self.credential_source.clone(),
595                    token_cache: self.token_cache.clone(),
596                },
597            )
598            .await?;
599
600            // Check if Claude wants to use tools
601            if let Some(content) = response["content"].as_array() {
602                let mut response_text = String::new();
603                let mut tool_uses = Vec::new();
604
605                // Process response content
606                for item in content {
607                    match item["type"].as_str() {
608                        Some("text") => {
609                            if let Some(text) = item["text"].as_str() {
610                                response_text.push_str(text);
611                            }
612                        }
613                        Some("tool_use") => {
614                            tool_uses.push(item.clone());
615                        }
616                        _ => {}
617                    }
618                }
619
620                // If no tool uses, return the text response
621                if tool_uses.is_empty() {
622                    return Ok(response_text);
623                }
624
625                // Add assistant's response to conversation (only content, role)
626                messages.push(json!({
627                    "role": "assistant",
628                    "content": content
629                }));
630
631                // Execute tools — parallel when multiple are requested
632                let mut tool_results = Vec::new();
633
634                if tool_uses.len() == 1 {
635                    // Single tool — run inline, no spawn overhead
636                    let tool_use = &tool_uses[0];
637                    if let (Some(tool_name), Some(tool_id)) =
638                        (tool_use["name"].as_str(), tool_use["id"].as_str())
639                    {
640                        let input = &tool_use["input"];
641                        let result = match self.tools.read().await.get(tool_name).cloned() {
642                            Some(tool) => {
643                                let input = self
644                                    .tools
645                                    .read()
646                                    .await
647                                    .translate_input_for_api_tool(tool_name, input.clone());
648                                let runtime_name = self
649                                    .tools
650                                    .read()
651                                    .await
652                                    .runtime_name_for_api(tool_name)
653                                    .to_string();
654                                let ctx = crate::ToolContext {
655                                    channels: crate::tools::ToolChannels {
656                                        tx_delta: None,
657                                        tx_events: None,
658                                    },
659                                    capabilities: crate::tools::ToolCapabilities {
660                                        watcher_exit_path: self.watcher_exit_path.clone(),
661                                        tool_register_tx: None,
662                                        session_manager: Some(self.session_manager.clone()),
663                                        subagent_registry: Some(self.subagent_registry.clone()),
664                                        event_queue: Some(self.event_queue.clone()),
665                                        secret_prompt: None,
666                                    },
667                                    limits: crate::tools::ToolLimits {
668                                        max_tool_output: self.max_tool_output,
669                                        max_tool_buffer: 256 * 1024,
670                                        bash_timeout: self.bash_timeout,
671                                        bash_max_timeout: self.bash_max_timeout,
672                                        subagent_timeout: self.subagent_timeout,
673                                    },
674                                };
675                                let decision = resolve_before_tool_call_decision(
676                                    input.clone(),
677                                    emit_before_tool_call(
678                                        &self.hook_bus,
679                                        tool_name,
680                                        Some(&runtime_name),
681                                        input.clone(),
682                                    )
683                                    .await,
684                                    None,
685                                    false,
686                                )
687                                .await;
688                                if let BeforeToolCallDecision::Block { reason } = decision {
689                                    format!("Tool call blocked by extension: {}", reason)
690                                } else {
691                                    let BeforeToolCallDecision::Continue { input } = decision
692                                    else {
693                                        unreachable!()
694                                    };
695                                    let input_for_hook = input.clone();
696                                    let output = match tool.execute(input, ctx).await {
697                                        Ok(output) => output,
698                                        Err(e) => format!("Tool execution failed: {}", e),
699                                    };
700                                    let output = emit_after_tool_call(
701                                        &self.hook_bus,
702                                        tool_name,
703                                        Some(&runtime_name),
704                                        input_for_hook,
705                                        output,
706                                        self.max_tool_output,
707                                    )
708                                    .await;
709                                    output
710                                }
711                            }
712                            None => format!("Unknown tool: {}", tool_name),
713                        };
714                        tool_results.push(json!({
715                            "type": "tool_result",
716                            "tool_use_id": tool_id,
717                            "content": HelperMethods::truncate_tool_result(&result, self.max_tool_output)
718                        }));
719                    }
720                } else {
721                    // Multiple tools — run in parallel with JoinSet
722                    let mut join_set = tokio::task::JoinSet::new();
723
724                    // Capture config values before spawning (can't borrow &self in 'static spawn)
725                    let cfg_max_tool_output = self.max_tool_output;
726                    let cfg_bash_timeout = self.bash_timeout;
727                    let cfg_bash_max_timeout = self.bash_max_timeout;
728                    let cfg_subagent_timeout = self.subagent_timeout;
729                    let session_mgr = self.session_manager.clone();
730                    let cfg_subagent_registry = self.subagent_registry.clone();
731                    let cfg_event_queue = self.event_queue.clone();
732                    let cfg_hook_bus = self.hook_bus.clone();
733
734                    for tool_use in &tool_uses {
735                        if let (Some(tool_name), Some(tool_id)) = (
736                            tool_use["name"].as_str().map(|s| s.to_string()),
737                            tool_use["id"].as_str().map(|s| s.to_string()),
738                        ) {
739                            let input = tool_use["input"].clone();
740                            let tools_snapshot = self.tools.read().await;
741                            let input =
742                                tools_snapshot.translate_input_for_api_tool(&tool_name, input);
743                            let runtime_name =
744                                tools_snapshot.runtime_name_for_api(&tool_name).to_string();
745                            let tool = tools_snapshot.get(&tool_name).cloned();
746                            drop(tools_snapshot);
747                            let exit_path = self.watcher_exit_path.clone();
748                            let session_mgr_inner = session_mgr.clone();
749                            let registry_inner = cfg_subagent_registry.clone();
750                            let event_queue_inner = cfg_event_queue.clone();
751                            let hook_bus_inner = cfg_hook_bus.clone();
752                            let tool_name_for_hook = tool_name.clone();
753                            let runtime_name_for_hook = runtime_name.clone();
754
755                            join_set.spawn(async move {
756                                let result = match tool {
757                                    Some(t) => {
758                                        let decision =
759                                            crate::runtime::resolve_before_tool_call_decision(
760                                                input.clone(),
761                                                crate::runtime::emit_before_tool_call(
762                                                    &hook_bus_inner,
763                                                    &tool_name_for_hook,
764                                                    Some(&runtime_name_for_hook),
765                                                    input.clone(),
766                                                )
767                                                .await,
768                                                None,
769                                                false,
770                                            )
771                                            .await;
772                                        if let crate::runtime::BeforeToolCallDecision::Block {
773                                            reason,
774                                        } = decision
775                                        {
776                                            format!("Tool call blocked by extension: {}", reason)
777                                        } else {
778                                            let crate::runtime::BeforeToolCallDecision::Continue {
779                                                input,
780                                            } = decision
781                                            else {
782                                                unreachable!()
783                                            };
784                                            let ctx = crate::ToolContext {
785                                                channels: crate::tools::ToolChannels {
786                                                    tx_delta: None,
787                                                    tx_events: None,
788                                                },
789                                                capabilities: crate::tools::ToolCapabilities {
790                                                    watcher_exit_path: exit_path,
791                                                    tool_register_tx: None,
792                                                    session_manager: Some(session_mgr_inner),
793                                                    subagent_registry: Some(registry_inner),
794                                                    event_queue: Some(event_queue_inner),
795                                                    secret_prompt: None,
796                                                },
797                                                limits: crate::tools::ToolLimits {
798                                                    max_tool_output: cfg_max_tool_output,
799                                                    max_tool_buffer: 256 * 1024,
800                                                    bash_timeout: cfg_bash_timeout,
801                                                    bash_max_timeout: cfg_bash_max_timeout,
802                                                    subagent_timeout: cfg_subagent_timeout,
803                                                },
804                                            };
805                                            let input_for_hook = input.clone();
806                                            let output = match t.execute(input, ctx).await {
807                                                Ok(output) => output,
808                                                Err(e) => format!("Tool execution failed: {}", e),
809                                            };
810                                            let output = crate::runtime::emit_after_tool_call(
811                                                &hook_bus_inner,
812                                                &tool_name_for_hook,
813                                                Some(&runtime_name_for_hook),
814                                                input_for_hook,
815                                                output,
816                                                cfg_max_tool_output,
817                                            )
818                                            .await;
819                                            output
820                                        }
821                                    }
822                                    None => format!("Unknown tool: {}", tool_name),
823                                };
824                                (tool_id, result)
825                            });
826                        }
827                    }
828
829                    // Collect results, preserving order by tool_id
830                    let mut results_map = std::collections::HashMap::new();
831                    while let Some(res) = join_set.join_next().await {
832                        match res {
833                            Ok((tool_id, result)) => {
834                                results_map.insert(tool_id, result);
835                            }
836                            Err(e) => {
837                                // Task panicked — log it but don't crash
838                                tracing::error!("Parallel tool task panicked: {}", e);
839                            }
840                        }
841                    }
842
843                    // Build tool_results in original order — every tool_use MUST have a result
844                    for tool_use in &tool_uses {
845                        if let Some(tool_id) = tool_use["id"].as_str() {
846                            let result = results_map.remove(tool_id).unwrap_or_else(|| {
847                                "Tool execution failed: task panicked".to_string()
848                            });
849                            tool_results.push(json!({
850                                "type": "tool_result",
851                                "tool_use_id": tool_id,
852                                "content": HelperMethods::truncate_tool_result(&result, self.max_tool_output)
853                            }));
854                        }
855                    }
856                }
857
858                // Add tool results to conversation
859                messages.push(json!({
860                    "role": "user",
861                    "content": tool_results
862                }));
863
864                // Continue the loop to get Claude's response with tool results
865            } else {
866                return Err(RuntimeError::Tool("Invalid response format".to_string()));
867            }
868        }
869    }
870
871    /// Run a prompt as a cancellable stream of [`StreamEvent`]s. Convenience wrapper
872    /// around [`run_stream_with_messages`] for single-turn usage.
873    pub async fn run_stream(
874        &self,
875        prompt: String,
876        cancel: CancellationToken,
877    ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
878        self.run_stream_with_messages(
879            vec![json!({"role": "user", "content": prompt})],
880            cancel,
881            None,
882            None,
883            false,
884        )
885        .await
886    }
887
888    /// Run a multi-turn conversation as a cancellable stream of [`StreamEvent`]s.
889    /// This is the main entry point for chat UIs and agents. Handles tool execution,
890    /// API retries, and dynamic tool registration (MCP) internally.
891    pub async fn run_stream_with_messages(
892        &self,
893        messages: Vec<Value>,
894        cancel: CancellationToken,
895        steering_rx: Option<mpsc::UnboundedReceiver<String>>,
896        secret_prompt: Option<crate::tools::SecretPromptHandle>,
897        auto_approve_confirms: bool,
898    ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
899        let (tx, rx) = mpsc::unbounded_channel();
900
901        // Refresh OAuth token if expired before starting the stream.
902        if let Err(e) = self.refresh_if_needed().await {
903            let _ = tx.send(StreamEvent::Session(SessionEvent::Error(e.to_string())));
904            let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
905            return Box::pin(UnboundedReceiverStream::new(rx));
906        }
907
908        // Clone the Arc, not the whole Runtime — the spawned task shares the
909        // same AuthState so mid-loop token refreshes are visible immediately.
910        let auth = Arc::clone(&self.auth);
911        let client = self.client.clone();
912        let credential_source = self.credential_source.clone();
913        let token_cache = self.token_cache.clone();
914        let model = self.model.clone();
915        let tools = self.tools.clone();
916        let system_prompt = self.system_prompt.clone();
917        let thinking_budget = self.thinking_budget;
918        let watcher_exit_path = self.watcher_exit_path.clone();
919        let max_tool_output = self.max_tool_output;
920        let bash_timeout = self.bash_timeout;
921        let bash_max_timeout = self.bash_max_timeout;
922        let subagent_timeout = self.subagent_timeout;
923        let api_retries = self.api_retries;
924        let session_manager = self.session_manager.clone();
925        // Opt into the 1M-context beta header only when the user explicitly
926        // requested 1M (via context_window setting). Default 200k matches
927        // Anthropic's claude-code default and gives smarter inference.
928        let subagent_registry = self.subagent_registry.clone();
929        let event_queue = self.event_queue.clone();
930        let options = api::ApiOptions {
931            use_1m_context: self.context_window_override == Some(1_000_000),
932            cache_ttl: self.cache_ttl,
933            ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
934            saw_1h_honored: self.saw_1h_honored.clone(),
935            credential_source: self.credential_source.clone(),
936            token_cache: self.token_cache.clone(),
937        };
938
939        let session = crate::runtime::stream::StreamSession {
940            auth,
941            client,
942            credential_source,
943            token_cache,
944            options,
945            api_retries,
946            model,
947            tools,
948            system_prompt,
949            thinking_budget,
950            tx: tx.clone(),
951            cancel,
952            steering_rx,
953            watcher_exit_path,
954            max_tool_output,
955            bash_timeout,
956            bash_max_timeout,
957            subagent_timeout,
958            session_manager,
959            subagent_registry,
960            event_queue,
961            secret_prompt,
962            hook_bus: self.hook_bus.clone(),
963            auto_approve_confirms,
964            telemetry_level: self.telemetry_level,
965        };
966
967        tokio::spawn(async move {
968            if let Err(e) = StreamMethods::run_stream_internal(session, messages).await {
969                let _ = tx.send(StreamEvent::Session(SessionEvent::Error(e.to_string())));
970            }
971            let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
972        });
973
974        Box::pin(UnboundedReceiverStream::new(rx))
975    }
976}
977
978impl Clone for Runtime {
979    fn clone(&self) -> Self {
980        Self {
981            client: self.client.clone(),
982            auth: Arc::clone(&self.auth),
983            model: self.model.clone(),
984            tools: self.tools.clone(),
985            system_prompt: self.system_prompt.clone(),
986            thinking_budget: self.thinking_budget,
987            context_window_override: self.context_window_override,
988            compaction_model: self.compaction_model.clone(),
989            subagent_registry: self.subagent_registry.clone(),
990            event_queue: self.event_queue.clone(),
991            watcher_exit_path: self.watcher_exit_path.clone(),
992            max_tool_output: self.max_tool_output,
993            bash_timeout: self.bash_timeout,
994            bash_max_timeout: self.bash_max_timeout,
995            subagent_timeout: self.subagent_timeout,
996            api_retries: self.api_retries,
997            telemetry_level: self.telemetry_level,
998            cache_diagnostics: self.cache_diagnostics,
999            cache_ttl: self.cache_ttl,
1000            // Subagents are their own session — fresh latches so a downgrade
1001            // in the subagent's chain surfaces its own (single) notice and
1002            // honored-state isn't inherited from the parent.
1003            ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1004            saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1005            // Subagents start their own request chain — inheriting the parent's
1006            // last msg_id would produce bogus `messages_changed` diagnostics.
1007            last_msg_id: Arc::new(Mutex::new(None)),
1008            session_manager: self.session_manager.clone(),
1009            hook_bus: self.hook_bus.clone(),
1010            reaper_handle: None, // Cloned runtimes don't own the reaper
1011            reaper_cancel: None, // Cloned runtimes don't own the reaper
1012            credential_source: self.credential_source.clone(),
1013            token_cache: self.token_cache.clone(), // shares the same cache (Arc inside)
1014        }
1015    }
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020    use super::*;
1021
1022    #[tokio::test]
1023    async fn confirm_without_prompt_fails_closed() {
1024        let result = resolve_before_tool_call_result(
1025            crate::extensions::hooks::events::HookResult::Confirm {
1026                message: "Run deploy?".into(),
1027            },
1028            None,
1029            false,
1030        )
1031        .await;
1032
1033        assert!(matches!(
1034            result,
1035            crate::extensions::hooks::events::HookResult::Block { reason }
1036                if reason.contains("requires confirmation") && reason.contains("Run deploy?")
1037        ));
1038    }
1039
1040    #[tokio::test]
1041    async fn modify_result_replaces_tool_input() {
1042        let result = resolve_before_tool_call_decision(
1043            serde_json::json!({"command":"rm -rf /"}),
1044            crate::extensions::hooks::events::HookResult::Modify {
1045                input: serde_json::json!({"command":"echo safe"}),
1046            },
1047            None,
1048            false,
1049        )
1050        .await;
1051
1052        match result {
1053            BeforeToolCallDecision::Continue { input } => {
1054                assert_eq!(input, serde_json::json!({"command":"echo safe"}));
1055            }
1056            BeforeToolCallDecision::Block { reason } => panic!("unexpected block: {reason}"),
1057        }
1058    }
1059
1060    #[tokio::test]
1061    async fn confirm_prompt_yes_continues() {
1062        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1063        let handle = crate::tools::SecretPromptHandle::new(tx);
1064
1065        let task = tokio::spawn(async move {
1066            let request = rx.recv().await.expect("confirm prompt request");
1067            assert_eq!(request.title, "Confirm tool call");
1068            assert!(request.prompt.contains("Run deploy?"));
1069            let _ = request.response_tx.send(Some("yes".to_string()));
1070        });
1071
1072        let result = resolve_before_tool_call_result(
1073            crate::extensions::hooks::events::HookResult::Confirm {
1074                message: "Run deploy?".into(),
1075            },
1076            Some(&handle),
1077            false,
1078        )
1079        .await;
1080
1081        task.await.unwrap();
1082        assert!(matches!(
1083            result,
1084            crate::extensions::hooks::events::HookResult::Continue
1085        ));
1086    }
1087
1088    #[tokio::test]
1089    async fn confirm_prompt_non_yes_blocks() {
1090        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1091        let handle = crate::tools::SecretPromptHandle::new(tx);
1092
1093        let task = tokio::spawn(async move {
1094            let request = rx.recv().await.expect("confirm prompt request");
1095            let _ = request.response_tx.send(Some("no".to_string()));
1096        });
1097
1098        let result = resolve_before_tool_call_result(
1099            crate::extensions::hooks::events::HookResult::Confirm {
1100                message: "Run deploy?".into(),
1101            },
1102            Some(&handle),
1103            false,
1104        )
1105        .await;
1106
1107        task.await.unwrap();
1108        assert!(matches!(
1109            result,
1110            crate::extensions::hooks::events::HookResult::Block { reason }
1111                if reason.contains("confirmation denied")
1112        ));
1113    }
1114
1115    #[test]
1116    fn test_max_tokens_for_model() {
1117        // Opus models should return 128000
1118        assert_eq!(
1119            HelperMethods::max_tokens_for_model("claude-opus-4-6"),
1120            128000
1121        );
1122        assert_eq!(
1123            HelperMethods::max_tokens_for_model("opus-something"),
1124            128000
1125        );
1126
1127        // Non-opus models should return 64000
1128        assert_eq!(
1129            HelperMethods::max_tokens_for_model("claude-sonnet-4-20250514"),
1130            64000
1131        );
1132        assert_eq!(HelperMethods::max_tokens_for_model("haiku"), 64000);
1133        assert_eq!(HelperMethods::max_tokens_for_model("claude-3-haiku"), 64000);
1134        assert_eq!(
1135            HelperMethods::max_tokens_for_model("some-other-model"),
1136            64000
1137        );
1138
1139        // Edge cases
1140        assert_eq!(HelperMethods::max_tokens_for_model(""), 64000);
1141        assert_eq!(HelperMethods::max_tokens_for_model("OPUS"), 64000); // Case sensitive - uppercase doesn't match
1142        assert_eq!(
1143            HelperMethods::max_tokens_for_model("model-opus-end"),
1144            128000
1145        ); // Contains "opus" anywhere
1146    }
1147
1148    #[test]
1149    fn test_truncate_tool_result() {
1150        let default_max = 30000;
1151
1152        // Short string should remain unchanged
1153        let short = "This is a short string.";
1154        assert_eq!(
1155            HelperMethods::truncate_tool_result(short, default_max),
1156            short
1157        );
1158
1159        // Exactly max should remain unchanged
1160        let exact = "x".repeat(30000);
1161        assert_eq!(
1162            HelperMethods::truncate_tool_result(&exact, default_max),
1163            exact
1164        );
1165
1166        // String longer than max should be truncated with notice
1167        let too_long = "x".repeat(30001);
1168        let truncated = HelperMethods::truncate_tool_result(&too_long, default_max);
1169
1170        // Should start with the truncated content
1171        assert!(truncated.starts_with(&"x".repeat(30000)));
1172
1173        // Should contain truncation notice with total char count
1174        assert!(truncated.contains("[truncated — 30001 total chars, showing first 30000]"));
1175
1176        // Should be longer than max (due to notice)
1177        assert!(truncated.len() > 30000);
1178
1179        // Test with a much longer string
1180        let very_long = "a".repeat(50000);
1181        let truncated_very_long = HelperMethods::truncate_tool_result(&very_long, default_max);
1182        assert!(
1183            truncated_very_long.contains("[truncated — 50000 total chars, showing first 30000]")
1184        );
1185        assert!(truncated_very_long.starts_with(&"a".repeat(30000)));
1186
1187        // Test with custom limit
1188        let custom_truncated = HelperMethods::truncate_tool_result(&very_long, 100);
1189        assert!(custom_truncated.starts_with(&"a".repeat(100)));
1190        assert!(custom_truncated.contains("[truncated — 50000 total chars, showing first 100]"));
1191    }
1192
1193    #[test]
1194    fn test_thinking_level_ranges() {
1195        use crate::core::models::thinking_level_for_budget;
1196
1197        // Sentinel 0 = "adaptive" (S172 — model decides)
1198        assert_eq!(thinking_level_for_budget(0), "adaptive");
1199
1200        // Low range: 1..=2048
1201        assert_eq!(thinking_level_for_budget(1), "low");
1202        assert_eq!(thinking_level_for_budget(1024), "low");
1203        assert_eq!(thinking_level_for_budget(2048), "low");
1204
1205        // Medium range: 2049..=4096
1206        assert_eq!(thinking_level_for_budget(2049), "medium");
1207        assert_eq!(thinking_level_for_budget(3000), "medium");
1208        assert_eq!(thinking_level_for_budget(4096), "medium");
1209
1210        // High range: 4097..=16384
1211        assert_eq!(thinking_level_for_budget(4097), "high");
1212        assert_eq!(thinking_level_for_budget(8192), "high");
1213        assert_eq!(thinking_level_for_budget(16384), "high");
1214
1215        // XHigh range: _ (everything else)
1216        assert_eq!(thinking_level_for_budget(16385), "xhigh");
1217        assert_eq!(thinking_level_for_budget(32768), "xhigh");
1218        assert_eq!(thinking_level_for_budget(100000), "xhigh");
1219    }
1220}