1use crate::{Result, RuntimeError, ToolRegistry};
2use futures::stream::Stream;
3use reqwest::Client;
4use serde_json::{json, Value};
5use std::path::{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_util::sync::CancellationToken;
12
13mod anthropic_quota;
14mod api;
15mod api_sync;
16pub mod attachments;
17mod auth;
18#[cfg(test)]
19mod body_golden;
20pub mod budget;
21mod cache_diagnosis;
22pub(crate) mod cloud_invoke;
23pub mod compaction;
24pub mod context;
25pub mod continuation;
26pub mod google_gemini;
27pub mod google_vertex;
28pub(crate) mod helpers;
29mod http_phases;
30mod image_limits;
31mod image_retention;
32pub mod keepwarm;
33pub mod tool_keepwarm;
34#[cfg(test)]
38#[path = "../../tests/support/cache_sim.rs"]
39pub(crate) mod cache_sim;
40pub mod message_time;
41pub mod openai;
42pub mod relay;
43mod request;
44mod request_encoding;
45mod sse;
46mod sse_types;
47mod stream;
48mod chat_tools;
49pub mod subagent;
50pub mod subagent_watch;
51pub mod telemetry;
52mod read_dedupe;
53pub mod tool_spill;
54pub mod trace;
55pub mod worker_context;
56pub(crate) mod transport;
57mod types;
58
59use api::ApiMethods;
60use auth::AuthMethods;
61use helpers::HelperMethods;
62use stream::StreamMethods;
63use types::AuthState;
64pub use types::{AgentEvent, CancelCause, LlmEvent, SessionEvent, StreamEvent, TurnCompletion};
65pub use stream::activation_policy;
66
67pub enum BeforeToolCallDecision {
69 Continue { input: Value },
70 Block { reason: String },
71}
72
73pub async fn emit_before_tool_call(
78 hook_bus: &Arc<crate::extensions::hooks::HookBus>,
79 tool_name: &str,
80 runtime_tool_name: Option<&str>,
81 input: Value,
82 session_id: Option<&str>,
83 cwd: Option<&Path>,
84) -> crate::extensions::hooks::events::HookResult {
85 let mut event = crate::extensions::hooks::events::HookEvent::before_tool_call(tool_name, input)
86 .with_session(session_id)
87 .with_cwd(cwd);
88 if let Some(runtime_tool_name) = runtime_tool_name {
89 event.tool_runtime_name = Some(runtime_tool_name.to_string());
90 }
91 hook_bus.emit(&event).await
92}
93
94pub use crate::tools::CONFIRM_ANSWER_ALLOW_ALL;
100
101const MAX_CONFIRM_MESSAGE_CHARS: usize = 1200;
105const MAX_CONFIRM_MESSAGE_LINES: usize = 12;
106
107fn cap_confirm_message(message: &str) -> String {
113 let message = redact_secret_text(&crate::session::sanitize_prompt_text(message));
114 let lines: Vec<&str> = message.lines().collect();
115 let over_lines = lines.len() > MAX_CONFIRM_MESSAGE_LINES;
116 let over_chars = message.chars().count() > MAX_CONFIRM_MESSAGE_CHARS;
117 if !over_lines && !over_chars {
118 return message;
119 }
120 let kept = lines[..lines.len().min(MAX_CONFIRM_MESSAGE_LINES)].join("\n");
121 let kept: String = kept.chars().take(MAX_CONFIRM_MESSAGE_CHARS).collect();
122 format!("{kept}\n…[message truncated by host]")
123}
124
125const MAX_REWRITE_PREVIEW_LINES: usize = 6;
128const MAX_REWRITE_PREVIEW_CHARS: usize = 400;
129
130fn redact_secret_text(text: &str) -> String {
139 crate::runtime::trace::export::redact_command_text(text)
140}
141
142fn redact_preview_value(value: &mut Value) {
145 crate::runtime::trace::export::redact_value(value);
146 match value {
147 Value::Object(map) => map.values_mut().for_each(redact_preview_value),
148 Value::Array(items) => items.iter_mut().for_each(redact_preview_value),
149 Value::String(s) => *s = redact_secret_text(s),
150 _ => {}
151 }
152}
153
154fn final_input_preview(input: &Value) -> String {
157 let mut input = input.clone();
158 redact_preview_value(&mut input);
159 let full = match input.get("command").and_then(Value::as_str) {
160 Some(cmd) => cmd.to_string(),
161 None => serde_json::to_string_pretty(&input).unwrap_or_else(|_| input.to_string()),
162 };
163 let lines: Vec<&str> = full.lines().collect();
164 let mut out: String = lines[..lines.len().min(MAX_REWRITE_PREVIEW_LINES)]
165 .iter()
166 .map(|l| format!(" {l}"))
167 .collect::<Vec<_>>()
168 .join("\n");
169 let mut cut = lines.len() > MAX_REWRITE_PREVIEW_LINES;
170 if out.chars().count() > MAX_REWRITE_PREVIEW_CHARS {
171 out = out.chars().take(MAX_REWRITE_PREVIEW_CHARS).collect();
172 cut = true;
173 }
174 if cut {
175 out.push_str("\n …[preview truncated]");
176 }
177 out
178}
179
180fn confirm_prompt_body(
186 message: &str,
187 sources: &[String],
188 tool: Option<&str>,
189 rewrite: Option<(&[String], &Value)>,
190) -> String {
191 let source = if sources.is_empty() {
192 "(unknown)".to_string()
193 } else {
194 sources.join(", ")
195 };
196 let rewritten = match rewrite {
197 Some((by, input)) => format!(
198 "Input rewritten by: {}\nInput that will run:\n{}\n",
199 if by.is_empty() { "(unknown)".to_string() } else { by.join(", ") },
200 final_input_preview(input)
201 ),
202 None => String::new(),
203 };
204 crate::session::sanitize_prompt_text(&format!(
208 "Extension: {source}\nTool: {}\n{rewritten}\nMessage from {source}:\n{}\n\nAllow this tool call?",
209 tool.unwrap_or("(unknown)"),
210 cap_confirm_message(message)
211 ))
212}
213
214fn approved(
217 modified_input: Option<Value>,
218) -> crate::extensions::hooks::events::HookResult {
219 match modified_input {
220 Some(input) => crate::extensions::hooks::events::HookResult::Modify { input },
221 None => crate::extensions::hooks::events::HookResult::Continue,
222 }
223}
224
225pub async fn resolve_before_tool_call_result(
234 hook_result: crate::extensions::hooks::events::HookResult,
235 secret_prompt: Option<&crate::tools::SecretPromptHandle>,
236 auto_approve_confirms: bool,
237 session_allow_all: Option<&crate::tools::SessionConfirmLatch>,
238) -> crate::extensions::hooks::events::HookResult {
239 match hook_result {
240 crate::extensions::hooks::events::HookResult::Confirm {
241 message,
242 sources,
243 generations,
244 tool,
245 modified_input,
246 modified_by,
247 } => {
248 let keys = crate::tools::latch_keys(&sources, &generations);
249 if auto_approve_confirms {
250 tracing::info!(message = %message, "confirm auto-approved (auto_approve_confirms=true)");
251 return approved(modified_input);
252 }
253 if session_allow_all.is_some_and(|latch| latch.allows_all(&keys)) {
254 tracing::info!(
255 message = %message,
256 extensions = ?sources,
257 "confirm auto-approved (extension allow-all latched)"
258 );
259 return approved(modified_input);
260 }
261
262 let Some(prompt) = secret_prompt else {
263 return crate::extensions::hooks::events::HookResult::Block {
264 reason: format!(
265 "Tool call requires confirmation but no interactive prompt is available: {}",
266 redact_secret_text(&crate::session::sanitize_prompt_text(&message))
267 ),
268 };
269 };
270
271 let scope = (session_allow_all.is_some() && !sources.is_empty()).then(|| {
274 crate::session::ConfirmScope {
275 extensions: sources.clone(),
276 tool: tool.clone(),
277 rewritten_by: if modified_input.is_some() {
278 modified_by.clone()
279 } else {
280 Vec::new()
281 },
282 }
283 });
284 let response = prompt
285 .prompt_scoped(
286 crate::session::CONFIRM_PROMPT_TITLE.to_string(),
287 confirm_prompt_body(
288 &message,
289 &sources,
290 tool.as_deref(),
291 modified_input.as_ref().map(|i| (modified_by.as_slice(), i)),
292 ),
293 scope,
294 )
295 .await;
296
297 match response.as_deref().map(str::trim) {
298 Some(answer)
299 if answer.eq_ignore_ascii_case("yes") || answer.eq_ignore_ascii_case("y") =>
300 {
301 approved(modified_input)
302 }
303 Some(answer) if answer.eq_ignore_ascii_case(CONFIRM_ANSWER_ALLOW_ALL) => {
304 match session_allow_all {
305 Some(latch) if latch.allow(&keys) => tracing::warn!(
306 extensions = ?sources,
307 "user chose Allow all: confirms from these extensions are auto-approved for the rest of this conversation"
308 ),
309 Some(_) => tracing::warn!(
310 "user chose Allow all on an unattributed or revoked confirm; approving this call only"
311 ),
312 None => tracing::warn!(
313 "user chose Allow all but this path has no session latch; approving this call only"
314 ),
315 }
316 approved(modified_input)
317 }
318 _ => crate::extensions::hooks::events::HookResult::Block {
319 reason: format!(
320 "Tool call confirmation denied: {}",
321 redact_secret_text(&crate::session::sanitize_prompt_text(&message))
322 ),
323 },
324 }
325 }
326 other => other,
327 }
328}
329
330pub async fn resolve_before_tool_call_decision(
332 original_input: Value,
333 hook_result: crate::extensions::hooks::events::HookResult,
334 secret_prompt: Option<&crate::tools::SecretPromptHandle>,
335 auto_approve_confirms: bool,
336 session_allow_all: Option<&crate::tools::SessionConfirmLatch>,
337) -> BeforeToolCallDecision {
338 match resolve_before_tool_call_result(
339 hook_result,
340 secret_prompt,
341 auto_approve_confirms,
342 session_allow_all,
343 )
344 .await
345 {
346 crate::extensions::hooks::events::HookResult::Block { reason } => {
347 BeforeToolCallDecision::Block { reason }
348 }
349 crate::extensions::hooks::events::HookResult::Modify { input } => {
350 BeforeToolCallDecision::Continue { input }
351 }
352 _ => BeforeToolCallDecision::Continue {
353 input: original_input,
354 },
355 }
356}
357
358const MAX_REPLACE_OUTPUT: usize = 1024 * 1024; #[allow(clippy::too_many_arguments)] pub async fn emit_after_tool_call(
384 hook_bus: &Arc<crate::extensions::hooks::HookBus>,
385 tool_name: &str,
386 runtime_tool_name: Option<&str>,
387 input: Value,
388 output: String,
389 max_tool_output: usize,
390 session_id: Option<&str>,
391 cwd: Option<&Path>,
392) -> String {
393 emit_after_tool_call_outcome(
394 hook_bus,
395 tool_name,
396 runtime_tool_name,
397 input,
398 output,
399 max_tool_output,
400 session_id,
401 cwd,
402 false,
403 )
404 .await
405 .output
406}
407
408pub(super) struct AfterToolCallOutcome {
411 pub output: String,
413 pub replaced: bool,
414 pub uncut: Option<String>,
418}
419
420impl AfterToolCallOutcome {
421 pub fn full_text(&self) -> &str {
423 self.uncut.as_deref().unwrap_or(&self.output)
424 }
425}
426
427#[allow(clippy::too_many_arguments)] pub(super) async fn emit_after_tool_call_outcome(
429 hook_bus: &Arc<crate::extensions::hooks::HookBus>,
430 tool_name: &str,
431 runtime_tool_name: Option<&str>,
432 input: Value,
433 output: String,
434 max_tool_output: usize,
435 session_id: Option<&str>,
436 cwd: Option<&Path>,
437 keep_uncut: bool,
438) -> AfterToolCallOutcome {
439 use crate::extensions::hooks::events::HookResult;
440 let original = output.clone();
442 let mut event =
443 crate::extensions::hooks::events::HookEvent::after_tool_call(tool_name, input, output)
444 .with_session(session_id)
445 .with_cwd(cwd);
446 if let Some(runtime_tool_name) = runtime_tool_name {
447 event.tool_runtime_name = Some(runtime_tool_name.to_string());
448 }
449 let mut replaced = false;
450 let post_hook = match hook_bus.emit(&event).await {
451 HookResult::Replace { mut output } => {
452 replaced = true;
453 if output.len() > MAX_REPLACE_OUTPUT {
454 tracing::warn!(
455 tool = %tool_name,
456 len = output.len(),
457 cap = MAX_REPLACE_OUTPUT,
458 "Extension Replace output exceeds cap — clamping",
459 );
460 let mut boundary = MAX_REPLACE_OUTPUT;
462 while boundary > 0 && !output.is_char_boundary(boundary) {
463 boundary -= 1;
464 }
465 output.truncate(boundary);
466 }
467 output
468 }
469 HookResult::Continue => original,
470 other => {
471 tracing::warn!(
472 tool = %tool_name,
473 ?other,
474 "Unexpected after_tool_call result — using original output",
475 );
476 original
477 }
478 };
479 let output =
483 crate::runtime::helpers::HelperMethods::truncate_tool_result(&post_hook, max_tool_output);
484 let uncut = (keep_uncut && post_hook.len() > max_tool_output).then_some(post_hook);
485 AfterToolCallOutcome {
486 output,
487 replaced,
488 uncut,
489 }
490}
491
492#[derive(Debug, Clone, Copy, PartialEq, Eq)]
495pub struct ReasoningClamp {
496 pub from: agent_core::reasoning::ReasoningLevel,
497 pub to: agent_core::reasoning::ReasoningLevel,
498}
499
500#[derive(Clone)]
503struct PromptReloadSource {
504 manifest: PathBuf,
505 context: agent_core::prompt::SelectionContext,
506 user_module: Option<agent_core::prompt::PromptModule>,
507 delegation_policy_digest: Option<String>,
508}
509
510pub struct Runtime {
511 client: Client,
512 anthropic_stream_client: Arc<std::sync::OnceLock<Client>>,
513 auth: Arc<RwLock<AuthState>>,
514 model: String,
515 tools: Arc<RwLock<ToolRegistry>>,
516 system_prompt: Option<String>,
517 effective_prompt: Option<agent_core::prompt::PromptStack>,
519 prompt_generation: u64,
520 prompt_reload_source: Option<PromptReloadSource>,
522 thinking_budget: u32,
523 named_level: Option<agent_core::reasoning::ReasoningLevel>,
527 explicit_reasoning: bool,
532 codex_request_role: crate::runtime::openai::catalog::CodexRequestRole,
535 context_window_override: Option<u64>,
540 pub(crate) continuation: continuation::SharedContinuation,
543 worker_context: worker_context::WorkerContext,
547 compaction_model: Option<String>,
549 compaction_mode: agent_core::compaction::CompactionMode,
551 compaction_exclusions: Vec<agent_core::compaction::ContentClass>,
553 remote_summarization_attempts: Arc<std::sync::atomic::AtomicU64>,
558 subagent_registry: Arc<Mutex<crate::runtime::subagent::SubagentRegistry>>,
560 orchestration: Option<Arc<crate::orchestration::OrchestrationRuntime>>,
562 event_queue: Arc<crate::events::EventQueue>,
564 max_tool_output: usize,
566 tool_spill_policy: tool_spill::ToolSpillPolicy,
572 tool_spill_root: PathBuf,
574 tool_spill_stats: Arc<tool_spill::ToolSpillStats>,
576 read_dedupe: bool,
579 thinking_display: crate::config::ThinkingDisplay,
582 tool_keepwarm: bool,
586 bash_timeout: u64,
587 bash_max_timeout: u64,
588 subagent_timeout: u64,
589 api_retries: u32,
590 refusal_retries: u32,
591 telemetry_level: crate::runtime::telemetry::TelemetryLevel,
593 telemetry_log_generations: u32,
596 telemetry_writer: Option<crate::runtime::telemetry::TelemetryWriter>,
602 trace_ctx: trace::TraceContext,
607 trace_controls: std::sync::Arc<trace::TraceControls>,
611 memory_backend: crate::memory_backend::MemoryBinding,
614 memory_bound: bool,
618 one_shot_trace_writer:
626 std::sync::Arc<std::sync::Mutex<Option<crate::runtime::telemetry::TelemetryWriter>>>,
627 capture_dir: std::path::PathBuf,
633 cache_diagnostics: bool,
635 cache_ttl: crate::core::config::CacheTtlSetting,
639 request_compression: request_encoding::RequestCompression,
643 ttl_downgrade_notified: std::sync::Arc<std::sync::atomic::AtomicBool>,
646 saw_1h_honored: std::sync::Arc<std::sync::atomic::AtomicBool>,
650 cache_anchor: std::sync::Arc<keepwarm::CacheAnchorCell>,
654 last_msg_id: Arc<Mutex<Option<String>>>,
658 cache_diag_rejected: Arc<std::sync::atomic::AtomicBool>,
661 session_manager: std::sync::Arc<crate::tools::shell::SessionManager>,
662 hook_bus: Arc<crate::extensions::hooks::HookBus>,
664 #[allow(dead_code)]
666 reaper_handle: Option<tokio::task::JoinHandle<()>>,
667 #[allow(dead_code)]
668 reaper_cancel: Option<tokio_util::sync::CancellationToken>,
669 credential_source: crate::auth::CredentialSource,
673 token_cache: crate::auth::TokenCache,
676 trusted_worker_models: Vec<agent_core::prompt::QualifiedModelId>,
680 subagent_limits: crate::config::SubagentLimits,
684 progressive_tool_disclosure: bool,
688 tools_always_loaded: Arc<[crate::tools::catalog::ToolId]>,
691 deferred_tool_loading: bool,
694 activation_confirm: agent_core::config::ActivationConfirm,
697 delegation_parent: Option<String>,
700 session_allow_all: std::sync::Arc<crate::tools::SessionConfirmLatch>,
705 mcp_runtime: Option<std::sync::Arc<crate::mcp::McpRuntimeManager>>,
709 mcp_session_scope: Option<std::sync::Arc<crate::mcp::McpSessionEndGuard>>,
713 extension_runtime: Option<std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>>,
717 extension_session_scope:
721 Option<std::sync::Arc<crate::extensions::lease::ExtensionSessionEndGuard>>,
722 turn_budget: crate::runtime::budget::TurnBudget,
725 owner_steer_budget: Option<Arc<crate::runtime::budget::OwnerSteerBudget>>,
729 message_clock: crate::runtime::message_time::MessageClock,
731 host_tool_session: crate::tools::activation::SessionId,
739 tool_catalog_prompt: Arc<std::sync::Mutex<Option<Option<String>>>>,
744 retained_tool_set: crate::tools::activation::RetainedSessionToolSet,
748 pending_tool_restore: crate::tools::activation::PendingToolRestore,
752 session_id: Option<String>,
756 cwd: Option<PathBuf>,
760 env: Option<crate::session::types::ToolEnv>,
764 env_stripped: Vec<String>,
766 env_warned: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
770}
771
772fn fresh_host_tool_session() -> crate::tools::activation::SessionId {
776 crate::tools::activation::SessionId::parse(&format!(
777 "runtime-{}-{}",
778 std::process::id(),
779 uuid::Uuid::new_v4()
780 ))
781 .expect("generated runtime session id is always valid")
782}
783
784const HTTP_READ_TIMEOUT: Duration = Duration::from_secs(90);
789
790fn build_http_client(read_timeout: Duration) -> reqwest::Result<Client> {
803 Client::builder()
804 .tls_built_in_webpki_certs(true)
805 .tls_built_in_native_certs(true)
806 .connect_timeout(Duration::from_secs(10))
807 .read_timeout(read_timeout)
808 .build()
809}
810
811pub fn build_host_http_client() -> Result<Client> {
815 build_http_client(HTTP_READ_TIMEOUT)
816 .map_err(|e| RuntimeError::Config(format!("Failed to build HTTP client: {}", e)))
817}
818
819pub(crate) struct RuntimeParts {
823 pub host: crate::host::HostParts,
824 pub session_manager: Arc<crate::tools::shell::SessionManager>,
825 pub reaper: Option<(tokio::task::JoinHandle<()>, CancellationToken)>,
826}
827
828impl RuntimeParts {
829 pub(crate) fn with_reaper(host: crate::host::HostParts) -> Self {
831 let session_manager = fresh_session_manager();
832 let cancel = CancellationToken::new();
833 let handle =
834 crate::tools::shell::session::start_reaper(session_manager.clone(), cancel.clone());
835 Self {
836 host,
837 session_manager,
838 reaper: Some((handle, cancel)),
839 }
840 }
841
842 #[cfg_attr(not(any(test, feature = "testing")), allow(dead_code))]
844 pub(crate) fn without_reaper(host: crate::host::HostParts) -> Self {
845 Self {
846 host,
847 session_manager: fresh_session_manager(),
848 reaper: None,
849 }
850 }
851}
852
853fn fresh_session_manager() -> Arc<crate::tools::shell::SessionManager> {
854 let config = crate::tools::shell::ShellConfig::default();
855 crate::tools::shell::SessionManager::new(config)
856}
857
858fn fresh_host_parts() -> Result<crate::host::HostParts> {
862 let client = build_host_http_client()?;
863 let capture_dir = trace::default_capture_dir();
868 let _ = trace::sweep_expired_captures(&capture_dir);
869 let spill_root = sweep_spill_root();
871 Ok(crate::host::HostParts {
872 client,
873 tools: Arc::new(RwLock::new(ToolRegistry::new())),
874 hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
875 credential_source: crate::auth::CredentialSource::Local,
876 token_cache: crate::auth::TokenCache::new(),
877 mcp_runtime: None,
878 extension_runtime: None,
879 capture_dir,
880 spill_root,
881 progressive_tool_disclosure: false,
882 tools_always_loaded: Arc::from([]),
883 })
884}
885
886pub(crate) fn sweep_spill_root() -> PathBuf {
890 let root = agent_core::core::tool_spill::default_spill_root();
891 let _ = agent_core::core::tool_spill::sweep_stale_spill(
892 &root,
893 agent_core::core::tool_spill::SPILL_MAX_AGE,
894 std::time::SystemTime::now(),
895 );
896 root
897}
898
899fn canonical_trusted_worker_model(model: &str) -> String {
903 let model = model.trim();
904 if let Some(id) = model.strip_prefix("claude/") {
905 format!("anthropic/{id}")
906 } else if model.starts_with("claude-") {
907 format!("anthropic/{model}")
908 } else {
909 model.to_owned()
910 }
911}
912
913impl Drop for Runtime {
917 fn drop(&mut self) {
918 if let Some(c) = &self.reaper_cancel {
919 c.cancel();
920 }
921 }
922}
923
924impl Runtime {
925 pub async fn new() -> Result<Self> {
926 Ok(Self::from_parts(RuntimeParts::with_reaper(
928 fresh_host_parts()?,
929 )))
930 }
931
932 pub(crate) fn from_parts(parts: RuntimeParts) -> Self {
935 let (auth_token, auth_type, refresh_token, token_expires) =
939 (String::new(), "oauth".to_string(), None, Some(0));
940 let RuntimeParts {
941 host,
942 session_manager,
943 reaper,
944 } = parts;
945 let (reaper_handle, reaper_cancel) = match reaper {
946 Some((h, c)) => (Some(h), Some(c)),
947 None => (None, None),
948 };
949 let mcp_runtime = host.mcp_runtime;
950 let extension_runtime = host.extension_runtime;
951
952 let mut runtime = Runtime {
953 client: host.client,
954 anthropic_stream_client: Default::default(),
955 auth: Arc::new(RwLock::new(AuthState {
956 auth_token,
957 auth_type,
958 refresh_token,
959 token_expires,
960 auth_file_mtime: None,
961 bound_credential: None,
962 })),
963 model: crate::models::default_model().to_string(),
964 tools: host.tools,
965 system_prompt: None,
966 effective_prompt: None,
967 prompt_generation: 0,
968 prompt_reload_source: None,
969 thinking_budget: 4096,
970 named_level: None,
971 explicit_reasoning: false,
972 codex_request_role: crate::runtime::openai::catalog::CodexRequestRole::Foreground,
973 context_window_override: None,
974 compaction_model: None,
975 compaction_mode: agent_core::compaction::CompactionMode::default(),
976 compaction_exclusions: Vec::new(),
977 remote_summarization_attempts: Arc::new(std::sync::atomic::AtomicU64::new(0)),
978 subagent_registry: Arc::new(Mutex::new(
979 crate::runtime::subagent::SubagentRegistry::new(),
980 )),
981 orchestration: None,
982 event_queue: Arc::new(crate::events::EventQueue::new(1000)),
983 max_tool_output: 30000,
984 tool_spill_policy: tool_spill::ToolSpillPolicy::OFF,
985 tool_spill_root: host.spill_root,
986 tool_spill_stats: Arc::default(),
987 read_dedupe: false,
988 thinking_display: crate::config::ThinkingDisplay::Summarized,
989 tool_keepwarm: false,
990 bash_timeout: 30,
991 bash_max_timeout: 300,
992 subagent_timeout: 300,
993 api_retries: 3,
994 refusal_retries: 2,
995 telemetry_level: crate::runtime::telemetry::TelemetryLevel::Off,
996 telemetry_log_generations: crate::runtime::telemetry::DEFAULT_MAX_GENERATIONS,
997 telemetry_writer: None,
998 trace_ctx: trace::TraceContext::disabled(),
999 trace_controls: std::sync::Arc::new(trace::TraceControls::new()),
1000 memory_backend: crate::memory_backend::MemoryBinding::for_cwd(
1004 std::env::current_dir().ok(),
1005 ),
1006 memory_bound: false,
1007 continuation: Arc::new(Mutex::new(continuation::ContinuationState::default())),
1008 worker_context: Default::default(),
1009 one_shot_trace_writer: std::sync::Arc::new(std::sync::Mutex::new(None)),
1010 capture_dir: host.capture_dir,
1011 cache_diagnostics: false,
1012 cache_ttl: crate::core::config::CacheTtlSetting::default(),
1013 request_compression: Default::default(),
1014 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1015 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1016 cache_anchor: Default::default(),
1017 last_msg_id: Arc::new(Mutex::new(None)),
1018 cache_diag_rejected: Default::default(),
1019 session_manager,
1020 hook_bus: host.hook_bus,
1021 reaper_handle,
1022 reaper_cancel,
1023 credential_source: host.credential_source,
1024 token_cache: host.token_cache,
1025 trusted_worker_models: Vec::new(),
1026 subagent_limits: crate::config::SubagentLimits::default(),
1027 progressive_tool_disclosure: host.progressive_tool_disclosure,
1028 tools_always_loaded: host.tools_always_loaded,
1029 deferred_tool_loading: true,
1030 activation_confirm: agent_core::config::ActivationConfirm::default(),
1031 delegation_parent: None,
1032 session_allow_all: std::sync::Arc::new(crate::tools::SessionConfirmLatch::new()),
1033 mcp_runtime: None,
1034 mcp_session_scope: None,
1035 extension_runtime: None,
1036 extension_session_scope: None,
1037 turn_budget: crate::runtime::budget::TurnBudget::for_role(
1038 crate::runtime::budget::TurnRole::Foreground,
1039 ),
1040 owner_steer_budget: None,
1041 message_clock: Default::default(),
1042 host_tool_session: fresh_host_tool_session(),
1043 tool_catalog_prompt: Arc::new(std::sync::Mutex::new(None)),
1044 retained_tool_set: Default::default(),
1045 pending_tool_restore: Default::default(),
1046 session_id: None,
1047 cwd: None,
1048 env: None,
1049 env_stripped: Vec::new(),
1050 env_warned: Default::default(),
1051 };
1052 if let Some(m) = mcp_runtime {
1056 runtime.install_mcp_runtime(m);
1057 }
1058 if let Some(m) = extension_runtime {
1059 runtime.install_extension_runtime(m);
1060 }
1061 runtime
1062 }
1063
1064
1065 #[cfg(any(test, feature = "testing"))]
1076 pub fn new_headless() -> Self {
1077 let client = build_http_client(HTTP_READ_TIMEOUT)
1078 .expect("reqwest client construction is infallible with built-in roots");
1079 let host = crate::host::HostParts {
1080 client,
1081 tools: Arc::new(RwLock::new(ToolRegistry::new())),
1082 hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
1083 credential_source: crate::auth::CredentialSource::Local,
1084 token_cache: crate::auth::TokenCache::new(),
1085 mcp_runtime: None,
1086 extension_runtime: None,
1087 capture_dir: trace::default_capture_dir(),
1088 spill_root: agent_core::core::tool_spill::default_spill_root(),
1089 progressive_tool_disclosure: false,
1090 tools_always_loaded: Arc::from([]),
1091 };
1092 let mut runtime = Self::from_parts(RuntimeParts::without_reaper(host));
1093 runtime.auth = Arc::new(RwLock::new(AuthState {
1094 auth_token: "test-token".to_string(),
1095 auth_type: "api_key".to_string(),
1096 refresh_token: None,
1097 token_expires: None,
1098 auth_file_mtime: None,
1099 bound_credential: None,
1100 }));
1101 runtime
1102 }
1103
1104 pub fn set_system_prompt(&mut self, prompt: String) {
1105 self.system_prompt = Some(prompt);
1106 }
1107
1108 pub fn system_prompt(&self) -> Option<&str> {
1109 self.system_prompt.as_deref()
1110 }
1111
1112 pub async fn effective_system_prompt(&self) -> Option<String> {
1124 let base = self.orchestrated_system_prompt().await;
1125 if self.effective_prompt.is_some() {
1127 return base;
1128 }
1129 match self.frozen_tool_catalog().await {
1130 Some(catalog) => Some(match base {
1131 Some(base) if !base.is_empty() => format!("{base}\n\n{catalog}"),
1132 _ => catalog,
1133 }),
1134 None => base,
1135 }
1136 }
1137
1138 pub async fn frozen_tool_catalog(&self) -> Option<String> {
1144 if !self.progressive_tool_disclosure
1145 || self.activation_confirm == agent_core::config::ActivationConfirm::Deny
1146 {
1147 return None;
1148 }
1149 if let Some(frozen) = self
1150 .tool_catalog_prompt
1151 .lock()
1152 .unwrap_or_else(std::sync::PoisonError::into_inner)
1153 .clone()
1154 {
1155 return frozen;
1156 }
1157 let rendered = {
1158 let registry = self.tools.read().await;
1159 if registry.get("activate_tools").is_none() {
1160 None
1161 } else {
1162 let core_set = chat_tools::chat_tool_set(
1163 self.host_tool_session.clone(),
1164 registry.catalog(),
1165 true,
1166 self.context_management_enabled(),
1167 &self.tools_always_loaded,
1168 );
1169 let core: std::collections::HashSet<String> = core_set
1170 .core_ids()
1171 .map(|id| id.as_str().to_string())
1172 .collect();
1173 crate::tools::prompt_catalog::render(
1175 ®istry.catalog().without(core_set.withheld()),
1176 &core,
1177 crate::tools::activation::is_activatable_record,
1178 )
1179 }
1180 };
1181 let mut slot = self
1182 .tool_catalog_prompt
1183 .lock()
1184 .unwrap_or_else(std::sync::PoisonError::into_inner);
1185 slot.get_or_insert(rendered).clone()
1187 }
1188
1189 async fn orchestrated_system_prompt(&self) -> Option<String> {
1193 if self.effective_prompt.is_some() {
1194 return self.system_prompt.clone();
1195 }
1196 if self.tools.read().await.get("subagent_start").is_none() {
1197 return self.system_prompt.clone();
1198 }
1199 let Ok(model) = crate::orchestration::canonical_foreground_identity(&self.model) else {
1200 return self.system_prompt.clone();
1201 };
1202 let workflow_mode = match self.reasoning_level() {
1203 agent_core::reasoning::ReasoningLevel::UltraCode => {
1204 Some(agent_core::prompt::WorkflowMode::UltraCode)
1205 }
1206 agent_core::reasoning::ReasoningLevel::Max => {
1207 Some(agent_core::prompt::WorkflowMode::Max)
1208 }
1209 agent_core::reasoning::ReasoningLevel::XHigh => {
1210 Some(agent_core::prompt::WorkflowMode::XHigh)
1211 }
1212 _ => None,
1213 };
1214 let Ok(context) = agent_core::prompt::SelectionContext::new(model, None)
1215 .map(|context| context.with_workflow_mode(workflow_mode))
1216 else {
1217 return self.system_prompt.clone();
1218 };
1219 agent_core::prompt::compose_orchestration_prompt(self.system_prompt.as_deref(), &context)
1220 }
1221
1222 pub fn effective_prompt(&self) -> Option<&agent_core::prompt::PromptStack> {
1223 self.effective_prompt.as_ref()
1224 }
1225
1226 pub fn prompt_generation(&self) -> u64 {
1227 self.prompt_generation
1228 }
1229
1230 pub fn retain_prompt_reload_source(
1231 &mut self,
1232 manifest: PathBuf,
1233 context: agent_core::prompt::SelectionContext,
1234 user_module: Option<agent_core::prompt::PromptModule>,
1235 delegation_policy_digest: Option<String>,
1236 ) {
1237 self.prompt_reload_source = Some(PromptReloadSource {
1238 manifest,
1239 context,
1240 user_module,
1241 delegation_policy_digest,
1242 });
1243 }
1244
1245 pub fn reload_prompt(&mut self) -> std::result::Result<u64, agent_core::prompt::PromptError> {
1247 let source = self.prompt_reload_source.clone().ok_or_else(|| {
1248 agent_core::prompt::PromptError::Invalid("no prompt manifest is active".into())
1249 })?;
1250 let raw = std::fs::read_to_string(&source.manifest).map_err(|_| {
1251 agent_core::prompt::PromptError::Invalid("prompt manifest is unavailable".into())
1252 })?;
1253 let manifest = agent_core::prompt::PromptManifest::parse(&raw)?;
1254 let reload_catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
1255 source.context.model(),
1256 manifest.delegation_catalog_candidates(),
1257 )
1258 .map_err(|error| agent_core::prompt::PromptError::Invalid(error.into()))?;
1259 let candidate_policy_digest = manifest
1260 .delegation_policy(source.context.model().clone(), &reload_catalog)?
1261 .map(|policy| policy.digest());
1262 if candidate_policy_digest != source.delegation_policy_digest {
1263 return Err(agent_core::prompt::PromptError::Invalid(
1264 "hot reload cannot safely change delegation policy".into(),
1265 ));
1266 }
1267 let registry = manifest.registry(source.manifest.parent())?;
1268 let candidate = agent_core::prompt::compile_prompt_stack(
1269 &manifest,
1270 ®istry,
1271 &source.context,
1272 source.user_module,
1273 )?;
1274 self.apply_prompt_stack(candidate)
1275 }
1276
1277 pub fn apply_prompt_stack(
1279 &mut self,
1280 candidate: agent_core::prompt::PromptStack,
1281 ) -> std::result::Result<u64, agent_core::prompt::PromptError> {
1282 if let Some(current) = &self.effective_prompt {
1283 current.validate_hot_reload(&candidate)?;
1284 }
1285 let composed = candidate.composed().to_owned();
1286 self.effective_prompt = Some(candidate);
1287 self.system_prompt = Some(composed);
1288 self.prompt_generation = self.prompt_generation.saturating_add(1);
1289 Ok(self.prompt_generation)
1290 }
1291
1292 pub fn prompt_inspection_json(&self) -> Option<String> {
1293 let mode = self
1294 .orchestration
1295 .as_ref()
1296 .map(|runtime| runtime.enforcement_mode())
1297 .unwrap_or(agent_core::orchestration::EnforcementMode::Off);
1298 self.effective_prompt.as_ref().and_then(|stack| {
1299 serde_json::to_string(&serde_json::json!({
1300 "generation": self.prompt_generation,
1301 "effective": stack.inspect(mode),
1302 "token_estimate": stack.composed().len().div_ceil(4),
1303 }))
1304 .ok()
1305 })
1306 }
1307
1308 pub fn install_mcp_runtime(&mut self, manager: std::sync::Arc<crate::mcp::McpRuntimeManager>) {
1313 self.mcp_session_scope = Some(std::sync::Arc::new(crate::mcp::McpSessionEndGuard::new(
1314 self.host_tool_session.clone(),
1315 std::sync::Arc::clone(&manager),
1316 )));
1317 self.mcp_runtime = Some(manager);
1318 }
1319
1320 pub fn set_turn_budget(&mut self, budget: crate::runtime::budget::TurnBudget) {
1324 self.turn_budget = budget;
1325 }
1326
1327 pub fn set_owner_steer_budget(
1331 &mut self,
1332 lift: Option<Arc<crate::runtime::budget::OwnerSteerBudget>>,
1333 ) {
1334 self.owner_steer_budget = lift;
1335 }
1336
1337 pub fn message_clock(&self) -> &crate::runtime::message_time::MessageClock {
1339 &self.message_clock
1340 }
1341
1342 pub fn set_message_clock(&mut self, clock: crate::runtime::message_time::MessageClock) {
1343 self.message_clock = clock;
1344 }
1345
1346 pub fn turn_budget(&self) -> &crate::runtime::budget::TurnBudget {
1348 &self.turn_budget
1349 }
1350
1351 pub fn install_extension_runtime(
1356 &mut self,
1357 manager: std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
1358 ) {
1359 manager.bind_host_scope(self.host_tool_session.clone());
1363 self.extension_session_scope = Some(std::sync::Arc::new(
1364 crate::extensions::lease::ExtensionSessionEndGuard::new(
1365 self.host_tool_session.clone(),
1366 std::sync::Arc::clone(&manager),
1367 ),
1368 ));
1369 self.extension_runtime = Some(manager);
1370 }
1371
1372 pub fn install_orchestration(
1373 &mut self,
1374 runtime: Arc<crate::orchestration::OrchestrationRuntime>,
1375 ) {
1376 for model in &self.trusted_worker_models {
1377 if let Err(error) = runtime.grant_worker_model(model.as_str()) {
1378 tracing::warn!(
1379 model = model.as_str(),
1380 error = %error,
1381 "failed to apply configured worker-model trust"
1382 );
1383 }
1384 }
1385 self.orchestration = Some(runtime);
1386 }
1387
1388 pub fn install_worker_orchestration(
1394 &mut self,
1395 runtime: Arc<crate::orchestration::OrchestrationRuntime>,
1396 ) {
1397 self.orchestration = Some(runtime);
1398 }
1399
1400 pub fn subagent_limits(&self) -> crate::config::SubagentLimits {
1402 self.subagent_limits
1403 }
1404
1405 pub fn set_delegation_parent(&mut self, parent: Option<String>) {
1406 self.delegation_parent = parent;
1407 }
1408
1409 pub fn orchestration(&self) -> Option<&Arc<crate::orchestration::OrchestrationRuntime>> {
1410 self.orchestration.as_ref()
1411 }
1412
1413 pub fn grant_worker_model(&self, model: &str) -> std::result::Result<(), String> {
1418 self.orchestration
1419 .as_ref()
1420 .ok_or_else(|| "delegation policy unavailable".to_string())?
1421 .grant_worker_model(model)
1422 }
1423
1424 pub fn set_model(&mut self, model: String) -> Option<ReasoningClamp> {
1428 self.try_set_model(model).ok().flatten()
1429 }
1430
1431 pub fn try_set_model(
1438 &mut self,
1439 model: String,
1440 ) -> std::result::Result<Option<ReasoningClamp>, String> {
1441 let trimmed = model.trim();
1446 let cleaned = trimmed
1447 .split_once(char::is_whitespace)
1448 .and_then(|(_, rest)| {
1449 let rest = rest.trim_start();
1450 let (latency, candidate) = rest.split_once(char::is_whitespace)?;
1451 let millis = latency.strip_suffix("ms")?;
1452 if !millis.is_empty() && millis.chars().all(|c| c.is_ascii_digit()) {
1453 let candidate = candidate.trim();
1454 if !candidate.is_empty() && !candidate.chars().any(char::is_whitespace) {
1455 return Some(candidate);
1456 }
1457 }
1458 None
1459 })
1460 .unwrap_or(trimmed);
1461 if self.prompt_reload_source.is_none()
1467 && self
1468 .orchestration
1469 .as_ref()
1470 .is_some_and(|current| !current.unreconciled_runtime_handles().is_empty())
1471 {
1472 return Err("model change blocked: workers require collection/reconciliation".into());
1473 }
1474 let orchestration_replacement = if self.prompt_reload_source.is_none()
1475 && self.orchestration.is_some()
1476 {
1477 let foreground = crate::orchestration::canonical_foreground_identity(cleaned)
1478 .map_err(|_| "model change blocked: unresolved foreground model".to_string())?;
1479 let replacement = crate::orchestration::OrchestrationRuntime::from_limits(
1480 foreground,
1481 self.subagent_limits,
1482 )
1483 .map_err(|_| "model change blocked: trusted worker catalog unavailable".to_string())?;
1484 for trusted in &self.trusted_worker_models {
1485 replacement
1486 .grant_worker_model(trusted.as_str())
1487 .map_err(|_| {
1488 "model change blocked: configured worker trust invalid".to_string()
1489 })?;
1490 }
1491 Some(Arc::new(replacement))
1492 } else {
1493 None
1494 };
1495 self.model = cleaned.to_owned();
1496 if let Some(replacement) = orchestration_replacement {
1500 self.orchestration = Some(replacement);
1501 }
1502 if !self.explicit_reasoning {
1508 if let Some(level) =
1509 crate::runtime::openai::catalog::validation::default_level_for_model(cleaned)
1510 {
1511 self.set_reasoning_level(level);
1512 }
1513 }
1514 Ok(self.clamp_reasoning_to_model())
1519 }
1520
1521 fn clamp_reasoning_to_model(&mut self) -> Option<ReasoningClamp> {
1527 use crate::runtime::openai::catalog::validation::{
1528 default_level_for_model, thinking_options_for_model, validate_reasoning_mutation,
1529 };
1530 let model = self.model.as_str();
1531 let from = self.reasoning_level();
1532 if validate_reasoning_mutation(model, from).is_ok() {
1533 return None;
1534 }
1535 let to = default_level_for_model(model)
1536 .filter(|level| validate_reasoning_mutation(model, *level).is_ok())
1537 .or_else(|| {
1538 thinking_options_for_model(model)
1539 .iter()
1540 .filter_map(|opt| agent_core::reasoning::ReasoningLevel::parse(opt))
1541 .find(|level| validate_reasoning_mutation(model, *level).is_ok())
1542 })?;
1543 let explicit = self.explicit_reasoning;
1544 self.set_reasoning_level(to);
1545 self.explicit_reasoning = explicit;
1546 Some(ReasoningClamp { from, to })
1547 }
1548
1549 pub fn set_tools(&mut self, tools: ToolRegistry) {
1550 self.tools = Arc::new(RwLock::new(tools));
1551 }
1552
1553 pub fn subagent_registry(&self) -> &Arc<Mutex<crate::runtime::subagent::SubagentRegistry>> {
1554 &self.subagent_registry
1555 }
1556
1557 pub fn event_queue(&self) -> &Arc<crate::events::EventQueue> {
1558 &self.event_queue
1559 }
1560
1561 pub fn set_event_queue(&mut self, queue: Arc<crate::events::EventQueue>) {
1565 self.event_queue = queue;
1566 }
1567
1568 pub fn hook_bus(&self) -> &Arc<crate::extensions::hooks::HookBus> {
1570 &self.hook_bus
1571 }
1572
1573 pub(crate) fn set_hook_bus(&mut self, bus: Arc<crate::extensions::hooks::HookBus>) {
1578 self.hook_bus = bus;
1579 }
1580
1581 pub fn session_manager(&self) -> &Arc<crate::tools::shell::SessionManager> {
1584 &self.session_manager
1585 }
1586
1587 pub fn host_tool_session_id(&self) -> &crate::tools::activation::SessionId {
1591 &self.host_tool_session
1592 }
1593
1594 pub fn set_session_id(&mut self, id: Option<String>) {
1599 self.session_id = id;
1600 }
1601
1602 pub fn begin_conversation(&mut self, id: Option<String>) {
1608 self.session_allow_all.revoke();
1609 self.session_allow_all = std::sync::Arc::new(crate::tools::SessionConfirmLatch::new());
1610 *self
1613 .last_msg_id
1614 .lock()
1615 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1616 self.set_session_id(id);
1617 }
1618
1619 pub fn session_id(&self) -> Option<&str> {
1620 self.session_id.as_deref()
1621 }
1622
1623 pub fn set_cwd(&mut self, cwd: Option<PathBuf>) {
1626 self.cwd = cwd;
1627 }
1628
1629 pub fn cwd(&self) -> Option<&Path> {
1630 self.cwd.as_deref()
1631 }
1632
1633 pub fn set_env(&mut self, env: Option<crate::session::types::SessionEnv>) {
1636 self.env = env.map(Into::into);
1637 }
1638
1639 pub fn set_tool_env(&mut self, env: Option<crate::session::types::ToolEnv>) {
1642 self.env = env;
1643 }
1644
1645 pub fn set_env_stripped(&mut self, stripped: Vec<String>) {
1646 self.env_stripped = stripped;
1647 }
1648
1649 pub fn env(&self) -> Option<&crate::session::types::SessionEnv> {
1651 self.env.as_ref().map(|e| &e.vars)
1652 }
1653
1654 pub fn command_chat(&self) -> crate::skills::commands::CommandChat<'_> {
1658 crate::skills::commands::CommandChat {
1659 session_id: self.session_id(),
1660 cwd: self.cwd(),
1661 memory_backend: &self.memory_backend,
1662 env: self.env.as_ref(),
1663 }
1664 }
1665
1666 pub fn env_stripped(&self) -> &[String] {
1667 &self.env_stripped
1668 }
1669
1670 #[cfg(any(test, feature = "testing"))]
1673 pub fn context_checkpoint_for_harness(&self) -> (&'static str, String) {
1674 let state = self
1675 .continuation
1676 .lock()
1677 .unwrap_or_else(std::sync::PoisonError::into_inner);
1678 (state.policy.phase().as_str(), state.note.clone())
1679 }
1680
1681 pub fn tools_shared(&self) -> Arc<RwLock<ToolRegistry>> {
1683 Arc::clone(&self.tools)
1684 }
1685
1686 pub async fn offered_tools_schema(&self) -> Arc<Vec<Value>> {
1691 let withheld = chat_tools::withheld_tools(self.context_management_enabled());
1692 let registry = self.tools.read().await;
1693 registry
1694 .tools_schema_without(&withheld)
1695 .unwrap_or_else(|| registry.tools_schema())
1696 }
1697
1698 pub fn model(&self) -> &str {
1699 &self.model
1700 }
1701
1702 pub fn http_client(&self) -> &Client {
1703 &self.client
1704 }
1705
1706 pub async fn prepare_model_capabilities(&self, model: &str) {
1714 if model.starts_with("muse/") {
1715 let broker = crate::auth::broker_from_source(
1716 &self.credential_source,
1717 &self.token_cache,
1718 self.client.clone(),
1719 );
1720 crate::runtime::openai::catalog::ensure_muse_catalog_for(model, &broker).await;
1721 }
1722 }
1723
1724 pub fn progressive_tool_disclosure(&self) -> bool {
1726 self.progressive_tool_disclosure
1727 }
1728
1729 pub fn carried_tool_activations(&self) -> Vec<crate::tools::activation::CarriedActivation> {
1735 let retained = self
1736 .retained_tool_set
1737 .lock()
1738 .unwrap_or_else(std::sync::PoisonError::into_inner)
1739 .clone();
1740 if let Some(shared) = retained {
1741 return shared
1742 .read()
1743 .unwrap_or_else(std::sync::PoisonError::into_inner)
1744 .carried_activations();
1745 }
1746 self.pending_tool_restore
1747 .lock()
1748 .unwrap_or_else(std::sync::PoisonError::into_inner)
1749 .clone()
1750 .unwrap_or_default()
1751 }
1752
1753 pub fn restore_tool_activations(
1761 &self,
1762 carried: Vec<crate::tools::activation::CarriedActivation>,
1763 ) {
1764 if carried.is_empty() {
1765 return;
1766 }
1767 let mut pending = self
1768 .pending_tool_restore
1769 .lock()
1770 .unwrap_or_else(std::sync::PoisonError::into_inner);
1771 match pending.as_mut() {
1772 Some(list) => {
1773 for c in carried {
1774 if !list.iter().any(|p| p.tool_id == c.tool_id) {
1775 list.push(c);
1776 }
1777 }
1778 }
1779 None => *pending = Some(carried),
1780 }
1781 }
1782
1783 pub fn activation_confirm(&self) -> agent_core::config::ActivationConfirm {
1785 self.activation_confirm
1786 }
1787
1788 pub fn set_activation_confirm(&mut self, mode: agent_core::config::ActivationConfirm) {
1791 self.activation_confirm = mode;
1792 }
1793
1794 #[cfg(any(test, feature = "testing"))]
1798 pub fn http_client_pool_id(&self) -> usize {
1799 const _: () = assert!(std::mem::size_of::<Client>() == std::mem::size_of::<usize>());
1800 unsafe { std::mem::transmute_copy::<Client, usize>(&self.client) }
1803 }
1804 pub fn set_thinking_budget(&mut self, budget: u32) {
1805 self.thinking_budget = budget;
1806 self.named_level = Some(agent_core::reasoning::ReasoningLevel::from_legacy_budget(
1808 budget,
1809 ));
1810 self.explicit_reasoning = false;
1812 }
1813
1814 pub fn set_reasoning_level(&mut self, level: agent_core::reasoning::ReasoningLevel) {
1820 self.named_level = Some(level);
1821 if let Some(budget) = level.to_legacy_budget() {
1822 self.thinking_budget = budget;
1823 }
1824 self.explicit_reasoning = false;
1826 }
1827
1828 pub fn set_reasoning_level_explicit(&mut self, level: agent_core::reasoning::ReasoningLevel) {
1833 self.set_reasoning_level(level);
1834 self.explicit_reasoning = true;
1835 }
1836
1837 pub fn restore_session_reasoning(&mut self, thinking_level: &str) -> Option<ReasoningClamp> {
1843 if let Some(level) = agent_core::reasoning::ReasoningLevel::parse(thinking_level) {
1844 self.set_reasoning_level_explicit(level);
1845 } else if let Some(budget) = crate::models::budget_for_thinking_level(thinking_level) {
1846 self.set_thinking_budget_explicit(budget);
1847 } else {
1848 return None;
1849 }
1850 self.clamp_reasoning_to_model()
1851 }
1852
1853 pub fn set_thinking_budget_explicit(&mut self, budget: u32) {
1858 self.thinking_budget = budget;
1859 self.named_level = Some(agent_core::reasoning::ReasoningLevel::from_legacy_budget(
1860 budget,
1861 ));
1862 self.explicit_reasoning = true;
1863 }
1864
1865 pub fn is_reasoning_explicit(&self) -> bool {
1867 self.explicit_reasoning
1868 }
1869
1870 pub fn thinking_budget_raw(&self) -> u32 {
1872 self.thinking_budget
1873 }
1874
1875 pub fn set_reasoning_level_checked(
1880 &mut self,
1881 level: agent_core::reasoning::ReasoningLevel,
1882 ) -> std::result::Result<(), String> {
1883 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
1884 &self.model,
1885 level,
1886 )?;
1887 if self.model.starts_with("anthropic/")
1888 && level == agent_core::reasoning::ReasoningLevel::UltraCode
1889 {
1890 if self.codex_request_role()
1891 != crate::runtime::openai::catalog::ExecutionRole::Foreground
1892 {
1893 return Err("ultracode_requires_foreground".into());
1894 }
1895 if self.effective_prompt.is_some() {
1896 return Err("typed_prompt_manifest_blocks_required_doctrine".into());
1897 }
1898 if self.orchestration.is_none() {
1899 return Err("ultracode_requires_orchestration".into());
1900 }
1901 let tools = self.tools.try_read().map_err(|_| {
1902 "ultracode prerequisite state is busy; refusing mutation".to_string()
1903 })?;
1904 let required = ["subagent_start", "subagent_status", "subagent_collect"];
1905 if !required.iter().all(|name| {
1906 tools
1907 .get(name)
1908 .is_some_and(|tool| tool.extension_id().is_none())
1909 }) {
1910 return Err("ultracode_requires_lifecycle_tools".into());
1911 }
1912 }
1913 self.set_reasoning_level_explicit(level);
1914 Ok(())
1915 }
1916
1917 pub fn set_thinking_budget_checked(&mut self, budget: u32) -> std::result::Result<(), String> {
1924 let level = agent_core::reasoning::ReasoningLevel::from_legacy_budget(budget);
1925 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
1926 &self.model,
1927 level,
1928 )?;
1929 self.set_thinking_budget_explicit(budget);
1930 Ok(())
1931 }
1932
1933 pub fn reasoning_level(&self) -> agent_core::reasoning::ReasoningLevel {
1934 self.named_level.unwrap_or_else(|| {
1935 agent_core::reasoning::ReasoningLevel::from_legacy_budget(self.thinking_budget)
1936 })
1937 }
1938
1939 pub(crate) fn set_codex_request_role(
1940 &mut self,
1941 role: crate::runtime::openai::catalog::CodexRequestRole,
1942 ) {
1943 self.codex_request_role = role;
1944 }
1945
1946 pub(crate) fn codex_request_role(&self) -> crate::runtime::openai::catalog::CodexRequestRole {
1947 self.codex_request_role
1948 }
1949
1950 pub(crate) fn codex_delegation_plan(
1951 model: &str,
1952 level: agent_core::reasoning::ReasoningLevel,
1953 role: crate::runtime::openai::catalog::CodexRequestRole,
1954 ) -> Option<crate::runtime::openai::catalog::CodexExecutionPlan> {
1955 use crate::runtime::openai::catalog::{plan_codex_execution, CodexRequestRole};
1956 if role != CodexRequestRole::Foreground
1957 || level != agent_core::reasoning::ReasoningLevel::Ultra
1958 || !model.starts_with("openai-codex/")
1959 {
1960 return None;
1961 }
1962 plan_codex_execution(model, level, role, None).ok()
1963 }
1964
1965 async fn authorized_anthropic_plan(
1966 &self,
1967 ) -> Result<Option<crate::runtime::openai::catalog::AnthropicExecutionPlan>> {
1968 if !self.model.starts_with("anthropic/") {
1969 return Ok(None);
1970 }
1971 let tools = self.tools.read().await;
1972 let builtin = |name: &str| {
1973 tools
1974 .get(name)
1975 .is_some_and(|tool| tool.extension_id().is_none())
1976 };
1977 let readiness = self
1978 .orchestration
1979 .as_ref()
1980 .and_then(|runtime| runtime.ultracode_readiness(&self.model).ok());
1981 let prerequisites = crate::runtime::openai::catalog::AnthropicPlanPrerequisites {
1982 orchestration_policy: self.orchestration.is_some(),
1983 foreground_worker_authorized: readiness.is_some(),
1984 concurrent_limit: readiness.map_or(0, |limits| limits.0),
1985 total_limit: readiness.map_or(0, |limits| limits.1),
1986 lifecycle_start: builtin("subagent_start"),
1987 lifecycle_status: builtin("subagent_status"),
1988 lifecycle_steer: builtin("subagent_steer"),
1989 lifecycle_collect: builtin("subagent_collect"),
1990 lifecycle_resume: builtin("subagent_resume"),
1991 };
1992 crate::runtime::openai::catalog::plan_anthropic_execution(
1993 &self.model,
1994 self.reasoning_level(),
1995 self.codex_request_role(),
1996 prerequisites,
1997 crate::runtime::openai::catalog::capability_cache::get(&self.model).and_then(|entry| {
1998 match entry.reasoning {
1999 crate::runtime::openai::catalog::ReasoningSupport::AnthropicAdaptive {
2000 adaptive,
2001 } => Some(adaptive),
2002 crate::runtime::openai::catalog::ReasoningSupport::None => Some(false),
2003 _ => None,
2004 }
2005 }),
2006 )
2007 .map(Some)
2008 .map_err(|error| RuntimeError::Config(error.to_string()))
2009 }
2010
2011 async fn validate_request_preflight(&self) -> Result<()> {
2014 self.validate_request_preflight_for(&self.model, self.codex_request_role)
2015 .await
2016 }
2017
2018 pub(crate) async fn validate_session_driver_preflight(&self) -> Result<()> {
2021 self.validate_request_preflight().await
2022 }
2023
2024 async fn validate_request_preflight_for(
2025 &self,
2026 model: &str,
2027 role: crate::runtime::openai::catalog::CodexRequestRole,
2028 ) -> Result<()> {
2029 let level = self.reasoning_level();
2030 if model.starts_with("anthropic/")
2031 && level == agent_core::reasoning::ReasoningLevel::UltraCode
2032 && self.effective_prompt.is_some()
2033 {
2034 return Err(RuntimeError::Config(
2035 "Anthropic execution plan rejected: typed_prompt_manifest_blocks_required_doctrine"
2036 .into(),
2037 ));
2038 }
2039 if model.starts_with("anthropic/")
2040 && matches!(
2041 level,
2042 agent_core::reasoning::ReasoningLevel::Max
2043 | agent_core::reasoning::ReasoningLevel::UltraCode
2044 | agent_core::reasoning::ReasoningLevel::Ultra
2045 )
2046 {
2047 let tools = self.tools.read().await;
2048 let builtin = |name: &str| {
2049 tools
2050 .get(name)
2051 .is_some_and(|tool| tool.extension_id().is_none())
2052 };
2053 let readiness = self
2054 .orchestration
2055 .as_ref()
2056 .and_then(|runtime| runtime.ultracode_readiness(model).ok());
2057 let prerequisites = crate::runtime::openai::catalog::AnthropicPlanPrerequisites {
2058 orchestration_policy: self.orchestration.is_some(),
2059 foreground_worker_authorized: readiness.is_some(),
2060 concurrent_limit: readiness.map_or(0, |limits| limits.0),
2061 total_limit: readiness.map_or(0, |limits| limits.1),
2062 lifecycle_start: builtin("subagent_start"),
2063 lifecycle_status: builtin("subagent_status"),
2064 lifecycle_steer: builtin("subagent_steer"),
2065 lifecycle_collect: builtin("subagent_collect"),
2066 lifecycle_resume: builtin("subagent_resume"),
2067 };
2068 drop(tools);
2069 let result = crate::runtime::openai::catalog::plan_anthropic_execution(
2070 model,
2071 level,
2072 role,
2073 prerequisites,
2074 crate::runtime::openai::catalog::capability_cache::get(model).and_then(|entry| {
2075 match entry.reasoning {
2076 crate::runtime::openai::catalog::ReasoningSupport::AnthropicAdaptive {
2077 adaptive,
2078 } => Some(adaptive),
2079 crate::runtime::openai::catalog::ReasoningSupport::None => Some(false),
2080 _ => None,
2081 }
2082 }),
2083 );
2084 match result {
2085 Ok(plan) => {
2086 tracing::debug!(event = "anthropic_mode_plan", qualified_model = %model, requested_level = %level, runtime_role = role.as_str(), execution_mode = ?plan.mode, wire_effort = plan.wire_effort.map_or("omitted", |effort| effort.as_str()), workflow = ?plan.workflow, credentials_attempted = false, network_attempted = false);
2087 return Ok(());
2088 }
2089 Err(error) => {
2090 tracing::debug!(event = "anthropic_mode_plan", qualified_model = %model, requested_level = %level, runtime_role = role.as_str(), decision = "deny", deny_code = error.code().as_str(), credentials_attempted = false, network_attempted = false);
2091 return Err(RuntimeError::Config(error.to_string()));
2092 }
2093 }
2094 }
2095 if model.starts_with("openai-codex/") {
2096 let plan =
2097 crate::runtime::openai::catalog::plan_codex_execution(model, level, role, None)
2098 .map_err(|error| {
2099 tracing::debug!(
2100 event = "codex_mode_plan",
2101 qualified_model = %model,
2102 requested_level = %level,
2103 runtime_role = role.as_str(),
2104 decision = "deny",
2105 deny_code = error.code().as_str(),
2106 network_attempted = false,
2107 "Codex request preflight denied"
2108 );
2109 RuntimeError::Config(error.to_string())
2110 })?;
2111
2112 if plan.automatic_delegation() {
2113 if self.orchestration.is_none() {
2114 tracing::debug!(
2115 event = "codex_mode_plan",
2116 qualified_model = %model,
2117 requested_level = %level,
2118 runtime_role = role.as_str(),
2119 decision = "deny",
2120 deny_code = "ultra_requires_orchestration",
2121 network_attempted = false,
2122 "Codex request preflight denied"
2123 );
2124 return Err(RuntimeError::Config(
2125 "Ultra requires an installed orchestration policy".to_string(),
2126 ));
2127 }
2128
2129 let tools = self.tools.read().await;
2130 let required = ["subagent_start", "subagent_status", "subagent_collect"];
2131 let tools_ready = required.iter().all(|name| {
2132 tools
2133 .get(name)
2134 .is_some_and(|tool| tool.extension_id().is_none())
2135 });
2136 if !tools_ready {
2137 tracing::debug!(
2138 event = "codex_mode_plan",
2139 qualified_model = %model,
2140 requested_level = %level,
2141 runtime_role = role.as_str(),
2142 decision = "deny",
2143 deny_code = "ultra_requires_subagent_tools",
2144 network_attempted = false,
2145 "Codex request preflight denied"
2146 );
2147 return Err(RuntimeError::Config(
2148 "Ultra requires built-in subagent_start, subagent_status, and subagent_collect tools"
2149 .to_string(),
2150 ));
2151 }
2152 }
2153 return Ok(());
2154 }
2155
2156 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(model, level)
2157 .map_err(|error| {
2158 tracing::debug!(
2159 event = "codex_mode_plan",
2160 qualified_model = %model,
2161 requested_level = %level,
2162 runtime_role = role.as_str(),
2163 decision = "deny",
2164 deny_code = "unsupported_reasoning_level",
2165 network_attempted = false,
2166 "request preflight denied"
2167 );
2168 RuntimeError::Config(error)
2169 })
2170 }
2171
2172 pub fn set_compaction_model(&mut self, model: Option<String>) {
2173 self.compaction_model = model;
2174 }
2175
2176 pub fn set_compaction_mode(&mut self, mode: agent_core::compaction::CompactionMode) {
2178 self.compaction_mode = mode;
2179 }
2180
2181 pub fn set_compaction_exclusions(
2183 &mut self,
2184 exclude: Vec<agent_core::compaction::ContentClass>,
2185 ) {
2186 self.compaction_exclusions = exclude;
2187 }
2188
2189 pub fn remote_summarization_attempts(&self) -> u64 {
2192 self.remote_summarization_attempts
2193 .load(std::sync::atomic::Ordering::SeqCst)
2194 }
2195
2196 pub fn compaction_policy(&self) -> crate::runtime::compaction::DisclosurePolicy {
2199 crate::runtime::compaction::DisclosurePolicy {
2200 mode: self.compaction_mode,
2201 exclude: self.compaction_exclusions.clone(),
2202 }
2203 }
2204
2205 pub fn set_context_window(&mut self, window: Option<u64>) {
2206 self.context_window_override = window;
2207 }
2208
2209 pub fn context_window_override(&self) -> Option<u64> {
2211 self.context_window_override
2212 }
2213
2214 pub fn compaction_model(&self) -> &str {
2217 self.compaction_model
2218 .as_deref()
2219 .unwrap_or("claude-sonnet-4-6")
2220 }
2221
2222 pub fn context_window(&self) -> u64 {
2223 self.context_window_override
2224 .unwrap_or_else(|| crate::models::context_window_for_model(&self.model))
2225 }
2226
2227 pub async fn assess_context(
2240 &self,
2241 messages: &[crate::SharedMessage],
2242 ) -> context::ContextAssessment {
2243 let system = self.effective_system_prompt().await;
2244 let schema = self.offered_tools_schema().await;
2245 let anchor = self
2246 .continuation
2247 .lock()
2248 .unwrap_or_else(std::sync::PoisonError::into_inner)
2249 .usage_anchor_for(&self.model)
2250 .cloned();
2251 context::assess(&context::ContextBudgetInputs {
2252 model: &self.model,
2253 provider_window: self.context_window(),
2254 system_prompt: system.as_deref(),
2255 tools_schema: &schema,
2256 messages,
2257 skill_contents: &[],
2258 memory_contents: &[],
2259 thinking_budget_tokens: self.thinking_budget as u64,
2260 next_tool_result_bytes: self.max_tool_output as u64,
2261 max_output_tokens: HelperMethods::max_tokens_for_model(&self.model),
2262 })
2263 .anchored(anchor.as_ref(), messages)
2264 }
2265
2266 pub(crate) fn inherit_memory_binding(&mut self, binding: &crate::memory_backend::MemoryBinding) {
2273 self.memory_backend = binding.for_worker();
2274 self.memory_bound = true;
2275 }
2276
2277 pub fn bind_memory(&mut self) {
2283 if self.memory_bound {
2284 return;
2285 }
2286 self.memory_backend = crate::memory_backend::MemoryBinding::for_cwd(
2290 self.cwd.clone().or_else(|| std::env::current_dir().ok()),
2291 );
2292 self.memory_bound = true;
2293 }
2294
2295 #[cfg(test)]
2296 #[allow(dead_code)] pub(crate) fn memory_backend_for_test(&self) -> crate::memory_backend::MemoryBinding {
2298 self.memory_backend.clone()
2299 }
2300
2301 pub fn apply_config(&mut self, config: &crate::config::SynapsConfig) {
2305 self.apply_config_inner(config, true);
2306 }
2307
2308 pub(crate) fn apply_config_keep_tools(&mut self, config: &crate::config::SynapsConfig) {
2315 self.apply_config_inner(config, false);
2316 }
2317
2318 fn apply_config_inner(&mut self, config: &crate::config::SynapsConfig, disable_tools: bool) {
2319 self.bind_memory();
2321 if let Some(ref model) = config.model {
2322 self.set_model(model.clone());
2323 }
2324 if let Some(level) = config.thinking_level {
2327 self.set_reasoning_level_explicit(level);
2328 } else if let Some(budget) = config.thinking_budget {
2329 self.set_thinking_budget_explicit(budget);
2330 }
2331 if let Some(clamp) = self.clamp_reasoning_to_model() {
2334 tracing::warn!(
2335 from = clamp.from.as_str(),
2336 to = clamp.to.as_str(),
2337 model = %self.model,
2338 "configured reasoning level not supported by model; clamped"
2339 );
2340 }
2341 self.context_window_override = config.context_window;
2342 self.continuation
2343 .lock()
2344 .unwrap_or_else(std::sync::PoisonError::into_inner)
2345 .config = config.context_management;
2346 self.compaction_model = config.compaction_model.clone();
2347 self.compaction_mode = config.compaction_mode;
2348 self.compaction_exclusions = config.compaction_exclude.clone();
2349 self.max_tool_output = config.max_tool_output;
2350 self.tool_spill_policy = tool_spill::ToolSpillPolicy::from_config(config);
2351 self.read_dedupe = config.read_dedupe;
2352 self.thinking_display = config.thinking_display;
2353 self.tool_keepwarm = config.cache_keepwarm_tools;
2354 self.bash_timeout = config.bash_timeout;
2355 self.bash_max_timeout = config.bash_max_timeout;
2356 self.subagent_timeout = config.subagent_timeout;
2357 self.api_retries = config.api_retries;
2358 self.refusal_retries = config.refusal_retries;
2359 self.telemetry_level =
2360 crate::runtime::telemetry::TelemetryLevel::from_str_key(&config.telemetry);
2361 self.telemetry_log_generations = config.telemetry_log_generations;
2362 self.sync_observability();
2363 self.cache_diagnostics = config.cache_diagnostics;
2364 self.cache_ttl = config.cache_ttl;
2365 self.request_compression = request_encoding::RequestCompression::from_config(
2366 config.request_compression,
2367 config.request_compression_level,
2368 );
2369 self.progressive_tool_disclosure = config.progressive_tool_disclosure;
2370 self.tools_always_loaded = Arc::from(config.tools_always_loaded.as_slice());
2371 self.deferred_tool_loading = config.tools_deferred_loading;
2372 self.activation_confirm = config.tools_activation_confirm;
2373 tracing::info!(
2374 mode = config.tools_activation_confirm.as_str(),
2375 auto_approve_confirms = config.server.auto_approve_confirms,
2376 "tools.activation_confirm: model-initiated activate_tools policy"
2377 );
2378 self.trusted_worker_models = config
2379 .favorite_models
2380 .iter()
2381 .filter_map(|model| {
2382 let canonical = canonical_trusted_worker_model(model);
2383 match agent_core::prompt::QualifiedModelId::parse(canonical) {
2384 Ok(model) => Some(model),
2385 Err(_) => {
2386 tracing::warn!(
2387 model = model.as_str(),
2388 "ignoring invalid favorite worker-model identity"
2389 );
2390 None
2391 }
2392 }
2393 })
2394 .collect();
2395 self.trusted_worker_models.sort();
2396 self.trusted_worker_models.dedup();
2397 self.subagent_limits = config.subagent;
2398 if let Some(orchestration) = &self.orchestration {
2399 for model in &self.trusted_worker_models {
2400 if let Err(error) = orchestration.grant_worker_model(model.as_str()) {
2401 tracing::warn!(
2402 model = model.as_str(),
2403 error = %error,
2404 "failed to apply configured worker-model trust"
2405 );
2406 }
2407 }
2408 }
2409 self.apply_auth_config(config);
2410
2411 if disable_tools && !config.disabled_tools.is_empty() {
2415 if let Ok(mut reg) = self.tools.try_write() {
2416 reg.disable(&config.disabled_tools);
2417 }
2418 }
2419 }
2420
2421 pub fn apply_auth_config(&mut self, config: &crate::config::SynapsConfig) {
2429 let new_source = config.auth.credential_source();
2430 if new_source != self.credential_source {
2431 self.token_cache.invalidate("anthropic");
2432 }
2433 self.credential_source = new_source;
2434 if self.credential_source.is_remote() {
2435 if let Ok(mut auth) = self.auth.try_write() {
2436 AuthMethods::scrub_for_remote(&mut auth);
2437 }
2438 }
2439 if let Ok(mut auth) = self.auth.try_write() {
2446 if auth.auth_type == "oauth" && !auth.auth_token.is_empty() {
2447 let selector = config
2448 .auth
2449 .account_selector(crate::auth::OAuthProviderId::Anthropic);
2450 let check = crate::runtime::auth::check_binding(
2451 &self.credential_source,
2452 &selector,
2453 auth.bound_credential.as_deref(),
2454 crate::runtime::auth::RefreshPoint::TurnStart,
2455 );
2456 if check != crate::runtime::auth::BindingCheck::Matches {
2457 AuthMethods::scrub_for_account_switch(&mut auth);
2458 self.token_cache.invalidate("anthropic");
2459 }
2460 }
2461 }
2462 crate::auth::set_global_broker(crate::auth::broker_from_source(
2467 &self.credential_source,
2468 &self.token_cache,
2469 self.client.clone(),
2470 ));
2471 }
2472
2473 pub fn thinking_budget(&self) -> u32 {
2474 self.thinking_budget
2475 }
2476
2477 pub fn max_tool_output(&self) -> usize {
2478 self.max_tool_output
2479 }
2480
2481 pub fn bash_timeout(&self) -> u64 {
2482 self.bash_timeout
2483 }
2484
2485 pub fn bash_max_timeout(&self) -> u64 {
2486 self.bash_max_timeout
2487 }
2488
2489 pub fn subagent_timeout(&self) -> u64 {
2490 self.subagent_timeout
2491 }
2492
2493 pub fn api_retries(&self) -> u32 {
2494 self.api_retries
2495 }
2496
2497 pub fn set_max_tool_output(&mut self, v: usize) {
2498 self.max_tool_output = v;
2499 }
2500
2501 pub fn tool_spill_policy(&self) -> tool_spill::ToolSpillPolicy {
2502 self.tool_spill_policy
2503 }
2504
2505 pub fn set_tool_spill_policy(&mut self, policy: tool_spill::ToolSpillPolicy) {
2506 self.tool_spill_policy = policy;
2507 }
2508
2509 pub fn read_dedupe(&self) -> bool {
2510 self.read_dedupe
2511 }
2512
2513 pub fn set_read_dedupe(&mut self, enabled: bool) {
2515 self.read_dedupe = enabled;
2516 }
2517
2518 pub fn thinking_display(&self) -> crate::config::ThinkingDisplay {
2520 self.thinking_display
2521 }
2522
2523 pub fn set_thinking_display(&mut self, display: crate::config::ThinkingDisplay) {
2524 self.thinking_display = display;
2525 }
2526
2527 pub(crate) fn wire_thinking_display(&self) -> crate::config::ThinkingDisplay {
2534 match self.codex_request_role {
2535 crate::runtime::openai::catalog::CodexRequestRole::Foreground => self.thinking_display,
2536 _ => crate::config::ThinkingDisplay::Summarized,
2537 }
2538 }
2539
2540 pub fn tool_keepwarm(&self) -> bool {
2541 self.tool_keepwarm
2542 }
2543
2544 pub fn set_tool_keepwarm(&mut self, enabled: bool) {
2546 self.tool_keepwarm = enabled;
2547 }
2548
2549 pub fn tool_spill_totals(&self) -> tool_spill::ToolSpillTotals {
2552 self.tool_spill_stats.totals()
2553 }
2554
2555 pub(crate) fn tool_spill(&self) -> tool_spill::ToolSpill {
2559 if !self.tool_spill_policy.is_enabled() {
2560 return tool_spill::ToolSpill::off();
2561 }
2562 let key = self
2563 .session_id
2564 .clone()
2565 .unwrap_or_else(|| self.trace_ctx.session_id().as_str().to_string());
2566 tool_spill::ToolSpill::new(
2567 self.tool_spill_policy,
2568 &self.tool_spill_root,
2569 &key,
2570 Arc::clone(&self.tool_spill_stats),
2571 )
2572 }
2573
2574 pub fn set_bash_timeout(&mut self, v: u64) {
2575 self.bash_timeout = v;
2576 }
2577
2578 pub fn set_bash_max_timeout(&mut self, v: u64) {
2579 self.bash_max_timeout = v;
2580 }
2581
2582 pub fn set_subagent_timeout(&mut self, v: u64) {
2583 self.subagent_timeout = v;
2584 }
2585
2586 pub(crate) fn apply_retry_config(&mut self, config: &crate::SynapsConfig) {
2589 self.api_retries = config.api_retries;
2590 self.refusal_retries = config.refusal_retries;
2591 }
2592
2593 pub fn refusal_retries(&self) -> u32 {
2594 self.refusal_retries
2595 }
2596
2597 pub fn set_api_retries(&mut self, v: u32) {
2598 self.api_retries = v;
2599 }
2600
2601 pub fn telemetry_level(&self) -> crate::runtime::telemetry::TelemetryLevel {
2602 self.telemetry_level
2603 }
2604
2605 pub fn set_telemetry_level(&mut self, level: crate::runtime::telemetry::TelemetryLevel) {
2606 self.telemetry_level = level;
2607 self.sync_observability();
2608 }
2609
2610 pub fn worker_telemetry(&self) -> crate::runtime::telemetry::WorkerTelemetry {
2612 crate::runtime::telemetry::WorkerTelemetry {
2613 level: self.telemetry_level,
2614 writer: self.telemetry_writer.clone(),
2615 parent_session_id: self.session_id.clone(),
2616 parent_runtime_session_id: Some(self.trace_ctx.session_id().as_str().to_string()),
2617 ..Default::default()
2618 }
2619 }
2620
2621 pub fn inherit_telemetry(&mut self, parent: &crate::runtime::telemetry::WorkerTelemetry) {
2632 match (&parent.writer, parent.level.enabled()) {
2633 (Some(writer), true) => {
2634 self.telemetry_level = parent.level;
2635 self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
2636 crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
2637 ));
2638 self.telemetry_writer = parent.worker_writer();
2641 }
2642 _ => self.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Off),
2643 }
2644 }
2645
2646 fn sync_observability(&mut self) {
2653 if self.telemetry_level.enabled() {
2654 if self.telemetry_writer.is_none() {
2655 let writer = crate::runtime::telemetry::TelemetryWriter::new(
2656 crate::runtime::telemetry::WriterOptions {
2657 max_generations: self.telemetry_log_generations,
2658 ..Default::default()
2659 },
2660 );
2661 self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
2662 crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
2663 ));
2664 self.telemetry_writer = Some(writer);
2665 }
2666 } else {
2667 self.telemetry_writer = None;
2671 self.trace_ctx = trace::TraceContext::disabled();
2672 }
2673 }
2674
2675 pub fn trace_context(&self) -> trace::TraceContext {
2677 self.trace_ctx.clone()
2678 }
2679
2680 pub(crate) fn effective_trace_context(&self) -> trace::TraceContext {
2701 match self.trace_controls.consume() {
2702 None => self.trace_ctx.clone(),
2703 Some(with_content) => {
2704 let base = if self.trace_ctx.enabled() {
2705 self.trace_ctx.clone()
2706 } else {
2707 let writer = crate::runtime::telemetry::TelemetryWriter::new(
2708 crate::runtime::telemetry::WriterOptions::default(),
2709 );
2710 *self
2714 .one_shot_trace_writer
2715 .lock()
2716 .expect("one-shot trace writer lock poisoned") = Some(writer.clone());
2717 self.trace_ctx
2718 .fork_with_sink(Arc::new(crate::runtime::telemetry::WriterTraceSink::new(
2719 writer,
2720 )))
2721 .with_one_shot_request_gate()
2722 };
2723 if with_content {
2724 base.with_content_capture(Arc::new(trace::ContentCapture::new(
2725 self.capture_dir.clone(),
2726 )))
2727 } else {
2728 base
2729 }
2730 }
2731 }
2732 }
2733
2734 pub fn trace_arm_next(&self, with_content: bool) {
2738 self.trace_controls.arm_next(with_content);
2739 }
2740
2741 pub fn trace_status(&self) -> trace::TraceStatusReport {
2746 let _ = trace::sweep_expired_captures(&self.capture_dir);
2747 trace::TraceStatusReport {
2748 persistent_enabled: self.telemetry_writer.is_some(),
2749 arm: self.trace_controls.peek(),
2750 trace_path: crate::runtime::telemetry::default_trace_log_path(),
2751 writer_stats: self.telemetry_writer.as_ref().map(|w| w.stats()),
2752 degraded_records: self.trace_ctx.degraded_records(),
2753 }
2754 }
2755
2756 pub fn context_report(&self, history: Option<&[crate::SharedMessage]>) -> trace::ContextReport {
2765 use trace::ReportValue;
2766 let (history_messages, history_bytes) = match history {
2767 Some(msgs) => (
2768 ReportValue::Count(msgs.len() as u64),
2769 ReportValue::Count(
2770 msgs.iter()
2771 .map(|m| {
2772 serde_json::to_vec(&**m)
2773 .map(|v| v.len() as u64)
2774 .unwrap_or(0)
2775 })
2776 .sum(),
2777 ),
2778 ),
2779 None => (ReportValue::Unavailable, ReportValue::Unavailable),
2780 };
2781 let tool_count = match self.tools.try_read() {
2784 Ok(tools) => ReportValue::Count(tools.iter_tools_sorted().len() as u64),
2785 Err(_) => ReportValue::Unavailable,
2786 };
2787 trace::ContextReport {
2788 model: self.model.clone(),
2789 system_prompt_bytes: ReportValue::Count(
2790 self.system_prompt().map(|s| s.len() as u64).unwrap_or(0),
2791 ),
2792 tool_count,
2793 history_messages,
2794 history_bytes,
2795 loaded_skills: ReportValue::Unavailable,
2796 loaded_memories: ReportValue::Unavailable,
2797 cache: self.trace_ctx.cache_snapshots().last_activity(),
2798 trace_enabled: self.trace_ctx.enabled(),
2799 writer_stats: self.telemetry_writer.as_ref().map(|w| w.stats()),
2800 degraded_records: self.trace_ctx.degraded_records(),
2801 }
2802 }
2803
2804 pub fn telemetry_writer(&self) -> Option<crate::runtime::telemetry::TelemetryWriter> {
2806 self.telemetry_writer.clone()
2807 }
2808
2809 pub(crate) fn request_telemetry(&self) -> Option<crate::runtime::telemetry::TelemetryWriter> {
2813 self.telemetry_writer
2814 .as_ref()
2815 .map(|w| w.with_session(self.session_id.clone()))
2816 }
2817
2818 pub fn shutdown_observability(
2832 &self,
2833 timeout: std::time::Duration,
2834 ) -> Option<crate::runtime::telemetry::ShutdownOutcome> {
2835 let _ = trace::sweep_expired_captures(&self.capture_dir);
2840 let ephemeral = self
2846 .one_shot_trace_writer
2847 .lock()
2848 .expect("one-shot trace writer lock poisoned")
2849 .take();
2850 let ephemeral_outcome = ephemeral.map(|w| w.shutdown(timeout));
2851 self.telemetry_writer
2852 .as_ref()
2853 .map(|w| w.shutdown(timeout))
2854 .or(ephemeral_outcome)
2855 }
2856
2857 pub async fn shutdown_observability_async(
2870 &self,
2871 timeout: std::time::Duration,
2872 ) -> Option<crate::runtime::telemetry::ShutdownOutcome> {
2873 let capture_dir = self.capture_dir.clone();
2877 let _ =
2878 tokio::task::spawn_blocking(move || trace::sweep_expired_captures(&capture_dir)).await;
2879 let ephemeral = self
2881 .one_shot_trace_writer
2882 .lock()
2883 .expect("one-shot trace writer lock poisoned")
2884 .take();
2885 let ephemeral_outcome = match ephemeral {
2886 Some(writer) => Some(writer.shutdown_async(timeout).await),
2887 None => None,
2888 };
2889 match self.telemetry_writer.clone() {
2890 Some(writer) => Some(writer.shutdown_async(timeout).await),
2891 None => ephemeral_outcome,
2892 }
2893 }
2894
2895 #[cfg(any(test, feature = "testing"))]
2899 pub fn install_observability_for_tests(
2900 &mut self,
2901 writer: crate::runtime::telemetry::TelemetryWriter,
2902 ) {
2903 self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
2904 crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
2905 ));
2906 self.telemetry_writer = Some(writer);
2907 }
2908
2909 pub fn cache_diagnostics(&self) -> bool {
2910 self.cache_diagnostics
2911 }
2912
2913 pub fn set_cache_diagnostics(&mut self, v: bool) {
2914 self.cache_diagnostics = v;
2915 }
2916
2917 pub fn set_request_compression(
2920 &mut self,
2921 mode: agent_core::config::RequestCompressionMode,
2922 level: u32,
2923 ) {
2924 self.request_compression = request_encoding::RequestCompression::from_config(mode, level);
2925 }
2926
2927 #[cfg(test)]
2929 pub(crate) fn request_compression_for_test(
2930 &self,
2931 ) -> (Option<agent_core::config::RequestCompressionMode>, u32) {
2932 self.request_compression.configured()
2933 }
2934
2935 pub fn cache_ttl(&self) -> crate::core::config::CacheTtlSetting {
2938 self.cache_ttl
2939 }
2940
2941 pub async fn resolved_cache_ttl(&self) -> crate::core::config::CacheTtl {
2948 let auth = self.auth.read().await;
2949 self.cache_ttl.resolve_for_auth_type(&auth.auth_type)
2950 }
2951
2952 pub fn share_ttl_latches(&mut self, parent: &Runtime) {
2956 self.ttl_downgrade_notified = parent.ttl_downgrade_notified.clone();
2957 self.saw_1h_honored = parent.saw_1h_honored.clone();
2958 self.cache_anchor = parent.cache_anchor.clone();
2959 self.last_msg_id = parent.last_msg_id.clone();
2962 self.cache_diag_rejected = parent.cache_diag_rejected.clone();
2963 }
2964
2965 pub(crate) fn cache_diagnosis(&self) -> Option<cache_diagnosis::CacheDiagnosis> {
2968 self.cache_diagnostics.then(|| {
2969 cache_diagnosis::CacheDiagnosis::new(
2970 Arc::clone(&self.last_msg_id),
2971 Arc::clone(&self.cache_diag_rejected),
2972 )
2973 })
2974 }
2975
2976 #[cfg(test)]
2980 pub(crate) async fn set_auth_kind_for_test(&self, kind: &str) {
2981 self.auth.write().await.auth_type = kind.to_string();
2982 }
2983
2984 pub fn set_cache_ttl(&mut self, ttl: impl Into<crate::core::config::CacheTtlSetting>) {
2990 self.cache_ttl = ttl.into();
2991 }
2992
2993 pub fn set_worker_context_settings(
2997 &mut self,
2998 settings: Option<worker_context::WorkerContextSettings>,
2999 ) {
3000 self.worker_context.settings = settings;
3001 }
3002
3003 pub fn attach_worker_transcripts(&mut self, sink: worker_context::WorkerTranscriptSink) {
3006 self.worker_context.sink = Some(sink);
3007 }
3008
3009 pub fn worker_context(&self) -> &worker_context::WorkerContext {
3010 &self.worker_context
3011 }
3012
3013 pub fn thinking_level(&self) -> &str {
3014 match self.named_level {
3017 Some(level) => level.as_str(),
3018 None => crate::core::models::thinking_level_for_budget(self.thinking_budget),
3019 }
3020 }
3021
3022 pub async fn refresh_if_needed(&self) -> Result<()> {
3024 self.refresh_if_needed_for_model(&self.model).await
3025 }
3026
3027 pub async fn reload_auth(&self) -> Result<String> {
3032 AuthMethods::reload_after_rejection(
3033 Arc::clone(&self.auth),
3034 &self.client,
3035 &self.credential_source,
3036 &self.token_cache,
3037 None,
3038 )
3039 .await?;
3040 let expires = self.auth.read().await.token_expires;
3041 let source = if self.credential_source.is_remote() {
3042 "remote broker"
3043 } else {
3044 "auth.json"
3045 };
3046 Ok(match expires {
3047 Some(exp) => {
3048 let mins = exp.saturating_sub(crate::epoch_millis()) / 60_000;
3049 format!("Anthropic credentials reloaded from {source} (valid ~{mins} min)")
3050 }
3051 None => format!("Anthropic credentials reloaded from {source}"),
3052 })
3053 }
3054
3055 pub(crate) async fn refresh_for_keepwarm(&self) -> Result<()> {
3058 if !crate::runtime::auth::model_is_anthropic(&self.model) {
3059 return Ok(());
3060 }
3061 AuthMethods::refresh_if_needed(
3062 Arc::clone(&self.auth),
3063 &self.client,
3064 &self.credential_source,
3065 &self.token_cache,
3066 Some(&self.model),
3067 crate::runtime::auth::RefreshPoint::KeepWarm,
3068 )
3069 .await
3070 }
3071
3072 async fn refresh_if_needed_for_model(&self, model: &str) -> Result<()> {
3073 if !crate::runtime::auth::model_is_anthropic(model) {
3076 return Ok(());
3077 }
3078 AuthMethods::refresh_if_needed(
3079 Arc::clone(&self.auth),
3080 &self.client,
3081 &self.credential_source,
3082 &self.token_cache,
3083 Some(model),
3084 crate::runtime::auth::RefreshPoint::TurnStart,
3085 )
3086 .await
3087 }
3088
3089 pub async fn compact_call(&self, messages: Vec<crate::SharedMessage>) -> Result<String> {
3095 self.remote_summarization_attempts
3098 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3099 let model = self.compaction_model();
3100 self.validate_request_preflight_for(
3101 model,
3102 crate::runtime::openai::catalog::CodexRequestRole::Internal,
3103 )
3104 .await?;
3105 self.refresh_if_needed_for_model(model).await?;
3106
3107 use crate::runtime::compaction::COMPACTION_SYSTEM_PROMPT;
3108
3109 ApiMethods::call_api_simple(
3110 &self.auth,
3111 &self.client,
3112 model,
3113 COMPACTION_SYSTEM_PROMPT,
3114 self.thinking_budget,
3115 self.reasoning_level(),
3116 &messages,
3117 self.api_retries,
3118 &api::ApiOptions {
3124 trace: self.trace_ctx.clone(),
3125 telemetry: self.telemetry_writer.clone(),
3126 request_compression: self.request_compression.clone(),
3128 ..api::ApiOptions::default()
3129 },
3130 )
3131 .await
3132 }
3133
3134 pub async fn run_stream(
3137 &self,
3138 prompt: String,
3139 cancel: CancellationToken,
3140 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
3141 self.run_stream_with_messages(
3142 vec![std::sync::Arc::new(
3143 json!({"role": "user", "content": prompt}),
3144 )],
3145 cancel,
3146 None,
3147 None,
3148 false,
3149 )
3150 .await
3151 }
3152
3153 pub fn session_allow_all(&self) -> &std::sync::Arc<crate::tools::SessionConfirmLatch> {
3156 &self.session_allow_all
3157 }
3158
3159 pub(crate) fn inherit_session_allow_all(
3161 &mut self,
3162 parent: Option<&std::sync::Arc<crate::tools::SessionConfirmLatch>>,
3163 ) {
3164 if let Some(parent) = parent {
3165 self.session_allow_all = parent.clone();
3166 }
3167 }
3168
3169 pub async fn run_stream_with_messages(
3173 &self,
3174 messages: Vec<crate::SharedMessage>,
3175 cancel: CancellationToken,
3176 steering_rx: Option<mpsc::UnboundedReceiver<String>>,
3177 secret_prompt: Option<crate::tools::SecretPromptHandle>,
3178 auto_approve_confirms: bool,
3179 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
3180 self.run_stream_tracked(
3181 messages,
3182 cancel,
3183 steering_rx,
3184 secret_prompt,
3185 auto_approve_confirms,
3186 TurnCompletion::new(),
3187 )
3188 .await
3189 }
3190
3191 pub async fn run_stream_tracked(
3195 &self,
3196 messages: Vec<crate::SharedMessage>,
3197 cancel: CancellationToken,
3198 steering_rx: Option<mpsc::UnboundedReceiver<String>>,
3199 secret_prompt: Option<crate::tools::SecretPromptHandle>,
3200 auto_approve_confirms: bool,
3201 completion: TurnCompletion,
3202 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
3203 let (tx, internal_rx) = mpsc::unbounded_channel();
3209 let bounded_rx =
3210 crate::runtime::relay::spawn_bounded_stream_relay(internal_rx, cancel.clone());
3211
3212 let turn_correlation_id = agent_core::next_turn_correlation_id();
3215
3216 if self
3217 .continuation
3218 .lock()
3219 .unwrap_or_else(std::sync::PoisonError::into_inner)
3220 .durability_blocked
3221 {
3222 let error = RuntimeError::Session(
3223 "context head save is unresolved; reload the session before further inference"
3224 .into(),
3225 );
3226 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3227 helpers::turn_error_for(&error, &turn_correlation_id),
3228 )));
3229 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3230 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3231 }
3232
3233 if let Err(error) = self.validate_request_preflight().await {
3234 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3235 helpers::turn_error_for(&error, &turn_correlation_id),
3236 )));
3237 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3238 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3239 }
3240
3241 let anthropic_execution_plan = match self.authorized_anthropic_plan().await {
3242 Ok(plan) => plan,
3243 Err(error) => {
3244 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3245 helpers::turn_error_for(&error, &turn_correlation_id),
3246 )));
3247 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3248 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3249 }
3250 };
3251
3252 if let Err(e) = self.refresh_if_needed().await {
3254 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3255 helpers::turn_error_for(&e, &turn_correlation_id),
3256 )));
3257 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3258 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3259 }
3260
3261 let auth = Arc::clone(&self.auth);
3264 let client = self.client.clone();
3265 let credential_source = self.credential_source.clone();
3266 let token_cache = self.token_cache.clone();
3267 let model = self.model.clone();
3268 let tools = self.tools.clone();
3269 let system_prompt = self.effective_system_prompt().await;
3270 let thinking_budget = self.thinking_budget;
3271 let reasoning_level = self.reasoning_level();
3272 let max_tool_output = self.max_tool_output;
3273 let bash_timeout = self.bash_timeout;
3274 let bash_max_timeout = self.bash_max_timeout;
3275 let subagent_timeout = self.subagent_timeout;
3276 let api_retries = self.api_retries;
3277 let refusal_retries = self.refusal_retries;
3278 let session_manager = self.session_manager.clone();
3279 let subagent_registry = self.subagent_registry.clone();
3283 let reaper_registry = Arc::clone(&subagent_registry);
3286 let reaper_orchestration = self.orchestration.clone();
3287 let event_queue = self.event_queue.clone();
3288 let options = api::ApiOptions {
3289 tool_context: None,
3292 use_1m_context: self.context_window_override == Some(1_000_000),
3293 cache_ttl: self.cache_ttl,
3294 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
3295 saw_1h_honored: self.saw_1h_honored.clone(),
3296 credential_source: self.credential_source.clone(),
3297 token_cache: self.token_cache.clone(),
3298 anthropic_base_url: None,
3299 anthropic_stream_client: self.anthropic_stream_client.clone(),
3300 request_compression: self.request_compression.clone(),
3301 anthropic_execution_plan,
3302 codex_request_role: self.codex_request_role(),
3303 thinking_display: self.wire_thinking_display(),
3304 retention_frontier: None,
3307 trace: self.effective_trace_context(),
3308 request_correlation: None,
3309 suppress_stream_deltas: false,
3310 telemetry: self.request_telemetry(),
3311 tool_session_id: Some(self.host_tool_session.clone()),
3314 session_tool_set: None,
3318 request_tools_schema: None,
3319 anthropic_tool_split: None,
3320 usage_counters: None,
3321 cache_anchor: Some(self.cache_anchor.clone()),
3324 keepwarm: None,
3325 cache_diagnosis: self.cache_diagnosis(),
3328 context_rollover: None,
3329 round_signal: None,
3331 };
3332
3333 let session = crate::runtime::stream::StreamSession {
3334 memory_backend: self.memory_backend.clone(),
3335 turn_completion: completion,
3336 context_window: self.context_window(),
3337 continuation: self.continuation.clone(),
3338 worker_context: self.worker_context.clone(),
3339 auth,
3340 client,
3341 credential_source,
3342 token_cache,
3343 options,
3344 api_retries,
3345 refusal_retries,
3346 model,
3347 tools,
3348 system_prompt,
3349 thinking_budget,
3350 reasoning_level,
3351 tx: tx.clone(),
3352 cancel,
3353 steering_rx,
3354 max_tool_output,
3355 tool_spill: self.tool_spill(),
3356 read_dedupe: self.read_dedupe,
3357 tool_keepwarm: self.tool_keepwarm.then(|| tool_keepwarm::ToolKeepWarm::new(
3358 self.trace_ctx.clone(),
3359 tool_keepwarm::interval_override(),
3360 )),
3361 bash_timeout,
3362 bash_max_timeout,
3363 subagent_timeout,
3364 session_manager,
3365 subagent_registry,
3366 event_queue,
3367 secret_prompt,
3368 hook_bus: self.hook_bus.clone(),
3369 session_id: self.session_id.clone(),
3370 cwd: self.cwd.clone(),
3371 env: self.env.clone(),
3372 env_stripped: self.env_stripped.clone(),
3373 env_warned: self.env_warned.clone(),
3374 auto_approve_confirms,
3375 session_allow_all: self.session_allow_all.clone(),
3376 telemetry_level: self.telemetry_level,
3377 orchestration: self.orchestration.clone(),
3378 delegation_parent: self.delegation_parent.clone(),
3379 turn_correlation_id: turn_correlation_id.clone(),
3380 progressive_tool_disclosure: self.progressive_tool_disclosure,
3381 tools_always_loaded: Arc::clone(&self.tools_always_loaded),
3382 deferred_tool_loading: self.deferred_tool_loading,
3383 activation_confirm: self.activation_confirm,
3384 tool_session_id: self.host_tool_session.clone(),
3385 retained_tool_set: std::sync::Arc::clone(&self.retained_tool_set),
3386 pending_tool_restore: std::sync::Arc::clone(&self.pending_tool_restore),
3387 mcp_runtime: self.mcp_runtime.clone(),
3388 mcp_session_scope: self.mcp_session_scope.clone(),
3389 extension_runtime: self.extension_runtime.clone(),
3390 extension_session_scope: self.extension_session_scope.clone(),
3391 turn_budget: self.turn_budget.clone(),
3392 owner_steer_budget: self.owner_steer_budget.clone(),
3393 message_clock: self.message_clock.clone(),
3394 };
3395
3396 tokio::spawn(async move {
3397 if let Err(e) = StreamMethods::run_stream_internal(session, messages).await {
3398 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3399 helpers::turn_error_for(&e, &turn_correlation_id),
3400 )));
3401 }
3402 crate::runtime::subagent::reap_finished(
3406 &reaper_registry,
3407 reaper_orchestration.as_deref(),
3408 );
3409 agent_core::core::memstat::log_turn_memory();
3410 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3411 });
3412
3413 Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx))
3414 }
3415}
3416
3417impl Clone for Runtime {
3418 fn clone(&self) -> Self {
3419 Self {
3420 client: self.client.clone(),
3421 anthropic_stream_client: self.anthropic_stream_client.clone(),
3422 auth: Arc::clone(&self.auth),
3423 model: self.model.clone(),
3424 tools: self.tools.clone(),
3425 system_prompt: self.system_prompt.clone(),
3426 effective_prompt: self.effective_prompt.clone(),
3427 prompt_generation: self.prompt_generation,
3428 prompt_reload_source: self.prompt_reload_source.clone(),
3429 thinking_budget: self.thinking_budget,
3430 named_level: self.named_level,
3431 explicit_reasoning: self.explicit_reasoning,
3432 codex_request_role: self.codex_request_role,
3433 context_window_override: self.context_window_override,
3434 continuation: Arc::clone(&self.continuation),
3435 worker_context: self.worker_context.clone(),
3436 compaction_model: self.compaction_model.clone(),
3437 compaction_mode: self.compaction_mode,
3438 compaction_exclusions: self.compaction_exclusions.clone(),
3439 remote_summarization_attempts: Arc::clone(&self.remote_summarization_attempts),
3440 subagent_registry: self.subagent_registry.clone(),
3441 orchestration: self.orchestration.clone(),
3442 event_queue: self.event_queue.clone(),
3443 max_tool_output: self.max_tool_output,
3444 tool_spill_policy: self.tool_spill_policy,
3445 tool_spill_root: self.tool_spill_root.clone(),
3446 tool_spill_stats: Arc::clone(&self.tool_spill_stats),
3447 read_dedupe: self.read_dedupe,
3448 thinking_display: self.thinking_display,
3449 tool_keepwarm: self.tool_keepwarm,
3450 bash_timeout: self.bash_timeout,
3451 bash_max_timeout: self.bash_max_timeout,
3452 subagent_timeout: self.subagent_timeout,
3453 api_retries: self.api_retries,
3454 refusal_retries: self.refusal_retries,
3455 telemetry_level: self.telemetry_level,
3456 telemetry_log_generations: self.telemetry_log_generations,
3457 telemetry_writer: self.telemetry_writer.clone(),
3461 trace_ctx: self.trace_ctx.clone(),
3462 trace_controls: Arc::clone(&self.trace_controls),
3463 memory_backend: self.memory_backend.clone(),
3464 memory_bound: self.memory_bound,
3465 one_shot_trace_writer: Arc::clone(&self.one_shot_trace_writer),
3466 capture_dir: self.capture_dir.clone(),
3467 cache_diagnostics: self.cache_diagnostics,
3468 cache_ttl: self.cache_ttl,
3469 request_compression: self.request_compression.clone(),
3470 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
3474 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
3475 cache_anchor: Default::default(),
3476 last_msg_id: Arc::new(Mutex::new(None)),
3479 cache_diag_rejected: Arc::clone(&self.cache_diag_rejected),
3480 session_manager: self.session_manager.clone(),
3481 hook_bus: self.hook_bus.clone(),
3482 reaper_handle: None, reaper_cancel: None, credential_source: self.credential_source.clone(),
3485 token_cache: self.token_cache.clone(), trusted_worker_models: self.trusted_worker_models.clone(),
3487 subagent_limits: self.subagent_limits,
3488 progressive_tool_disclosure: self.progressive_tool_disclosure,
3489 tools_always_loaded: Arc::clone(&self.tools_always_loaded),
3490 deferred_tool_loading: self.deferred_tool_loading,
3491 activation_confirm: self.activation_confirm,
3492 delegation_parent: self.delegation_parent.clone(),
3493 session_allow_all: self.session_allow_all.clone(),
3494 mcp_runtime: self.mcp_runtime.clone(),
3495 mcp_session_scope: self.mcp_session_scope.clone(),
3498 extension_runtime: self.extension_runtime.clone(),
3499 extension_session_scope: self.extension_session_scope.clone(),
3501 turn_budget: self.turn_budget.clone(),
3502 owner_steer_budget: self.owner_steer_budget.clone(),
3503 message_clock: self.message_clock.clone(),
3504 host_tool_session: self.host_tool_session.clone(),
3509 tool_catalog_prompt: Arc::clone(&self.tool_catalog_prompt),
3510 retained_tool_set: std::sync::Arc::clone(&self.retained_tool_set),
3511 pending_tool_restore: std::sync::Arc::clone(&self.pending_tool_restore),
3512 session_id: self.session_id.clone(),
3514 cwd: self.cwd.clone(),
3515 env: self.env.clone(),
3516 env_stripped: self.env_stripped.clone(),
3517 env_warned: self.env_warned.clone(),
3518 }
3519 }
3520}
3521
3522#[cfg(test)]
3523mod tests {
3524 use super::*;
3525
3526 #[test]
3531 fn host_tool_session_shared_by_clones_fresh_per_runtime() {
3532 let rt = Runtime::new_headless();
3533 let clone = rt.clone();
3534 assert_eq!(
3535 rt.host_tool_session_id(),
3536 clone.host_tool_session_id(),
3537 "clones share the live registry and therefore the host session"
3538 );
3539
3540 let other = Runtime::new_headless();
3541 assert_ne!(
3542 rt.host_tool_session_id(),
3543 other.host_tool_session_id(),
3544 "independently constructed runtimes must never share a session"
3545 );
3546 }
3547
3548 #[test]
3552 fn handed_activations_are_carried_on_before_the_first_turn() {
3553 use crate::tools::activation::{CarriedActivation, CarriedProvenance};
3554 let probe = |name: &str| CarriedActivation {
3555 tool_id: format!("builtin:{name}"),
3556 schema_digest: "ab".repeat(32),
3557 provenance: CarriedProvenance::BuiltinRuntime,
3558 };
3559 let rt = Runtime::new_headless();
3560 assert!(rt.carried_tool_activations().is_empty());
3561 rt.restore_tool_activations(vec![probe("subagent_start")]);
3562 rt.restore_tool_activations(Vec::new());
3563 rt.restore_tool_activations(vec![probe("subagent_start"), probe("subagent_status")]);
3564 assert_eq!(
3565 rt.carried_tool_activations(),
3566 vec![probe("subagent_start"), probe("subagent_status")],
3567 "merged without duplicates; an empty hand-off clears nothing"
3568 );
3569 assert_eq!(rt.clone().carried_tool_activations().len(), 2);
3570 assert!(Runtime::new_headless().carried_tool_activations().is_empty());
3571 }
3572
3573 #[test]
3578 fn telemetry_level_gates_shared_observability_sink() {
3579 let mut rt = Runtime::new_headless();
3580 assert!(!rt.trace_context().enabled(), "off by default");
3581 assert!(rt.telemetry_writer().is_none());
3582
3583 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Basic);
3584 assert!(rt.trace_context().enabled(), "basic enables the trace sink");
3585 assert!(rt.telemetry_writer().is_some());
3586
3587 let ctx_before = format!("{:?}", rt.trace_context());
3589 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Full);
3590 assert_eq!(format!("{:?}", rt.trace_context()), ctx_before);
3591
3592 let clone = rt.clone();
3594 assert!(clone.trace_context().enabled());
3595 assert_eq!(format!("{:?}", clone.trace_context()), ctx_before);
3596 assert!(clone.telemetry_writer().is_some());
3597
3598 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Off);
3599 assert!(!rt.trace_context().enabled(), "off disables both");
3600 assert!(rt.telemetry_writer().is_none());
3601 assert!(rt
3602 .shutdown_observability(std::time::Duration::ZERO)
3603 .is_none());
3604 }
3605
3606 #[tokio::test]
3612 async fn shutdown_observability_async_epilogue_contract() {
3613 let rt = Runtime::new_headless();
3615 let budget = crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT;
3616 assert!(rt.shutdown_observability_async(budget).await.is_none());
3617 assert!(rt.shutdown_observability_async(budget).await.is_none());
3618
3619 let tmp = tempfile::tempdir().unwrap();
3621 let telemetry_path = tmp.path().join("synaps/api-log.jsonl");
3622 let mut rt = Runtime::new_headless();
3623 let writer = crate::runtime::telemetry::TelemetryWriter::new(
3624 crate::runtime::telemetry::WriterOptions {
3625 telemetry_path: Some(telemetry_path.clone()),
3626 trace_path: Some(tmp.path().join("synaps/request-trace.jsonl")),
3627 ..Default::default()
3628 },
3629 );
3630 rt.install_observability_for_tests(writer.clone());
3631 writer.enqueue_telemetry(crate::runtime::telemetry::TelemetryRecord {
3632 ts: 7,
3633 model: "claude-sonnet-4-6".to_string(),
3634 ..Default::default()
3635 });
3636 let outcome = rt
3637 .shutdown_observability_async(budget)
3638 .await
3639 .expect("writer installed");
3640 assert!(outcome.is_flushed());
3641 assert_eq!(outcome.stats().written, 1);
3642 assert_eq!(
3643 std::fs::read_to_string(&telemetry_path)
3644 .unwrap()
3645 .lines()
3646 .count(),
3647 1,
3648 "queued final record must be on disk after the epilogue flush"
3649 );
3650 assert!(rt
3652 .shutdown_observability_async(budget)
3653 .await
3654 .expect("writer still installed")
3655 .is_flushed());
3656
3657 let mut rt = Runtime::new_headless();
3659 let slow = crate::runtime::telemetry::TelemetryWriter::new(
3660 crate::runtime::telemetry::WriterOptions {
3661 telemetry_path: Some(tmp.path().join("slow/api-log.jsonl")),
3662 trace_path: Some(tmp.path().join("slow/request-trace.jsonl")),
3663 write_delay: Some(std::time::Duration::from_millis(300)),
3664 ..Default::default()
3665 },
3666 );
3667 rt.install_observability_for_tests(slow.clone());
3668 for _ in 0..10 {
3669 slow.enqueue_telemetry(crate::runtime::telemetry::TelemetryRecord::default());
3670 }
3671 let start = std::time::Instant::now();
3672 let outcome = rt
3673 .shutdown_observability_async(std::time::Duration::from_millis(200))
3674 .await
3675 .expect("writer installed");
3676 assert!(!outcome.is_flushed(), "10×300ms cannot drain in 200ms");
3677 assert!(
3680 start.elapsed() < std::time::Duration::from_millis(1500),
3681 "bounded epilogue must not block exit: {:?}",
3682 start.elapsed()
3683 );
3684 }
3685
3686 #[tokio::test]
3691 async fn one_shot_ephemeral_writer_is_retained_and_drained_at_shutdown() {
3692 let rt = Runtime::new_headless();
3693 assert!(!rt.trace_context().enabled(), "telemetry off");
3694 rt.trace_arm_next(false);
3695 let armed = rt.effective_trace_context();
3696 assert!(armed.enabled(), "armed ephemeral context traces");
3697 let budget = crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT;
3700 let outcome = rt
3701 .shutdown_observability_async(budget)
3702 .await
3703 .expect("retained one-shot writer must be drained by the epilogue");
3704 assert!(outcome.is_flushed());
3705 assert!(rt.shutdown_observability_async(budget).await.is_none());
3707 }
3708
3709 #[tokio::test]
3715 #[serial_test::serial(synaps_base_dir)]
3716 async fn startup_and_shutdown_paths_sweep_expired_captures() {
3717 struct BaseDirGuard(Option<String>);
3718 impl Drop for BaseDirGuard {
3719 fn drop(&mut self) {
3720 match self.0.take() {
3721 Some(old) => std::env::set_var("SYNAPS_BASE_DIR", old),
3722 None => std::env::remove_var("SYNAPS_BASE_DIR"),
3723 }
3724 }
3725 }
3726 let tmp = tempfile::tempdir().unwrap();
3727 let guard = BaseDirGuard(std::env::var("SYNAPS_BASE_DIR").ok());
3728 std::env::set_var("SYNAPS_BASE_DIR", tmp.path());
3729
3730 let cap_dir = trace::default_capture_dir();
3731 assert!(
3732 cap_dir.starts_with(tmp.path()),
3733 "capture dir must be private to the test"
3734 );
3735 let stale_id = trace::TraceId::new("req-stale").unwrap();
3736 let plant_stale = || {
3737 agent_core::core::private_fs::ensure_private_dir(&cap_dir).unwrap();
3738 let stale = trace::controls::ContentCaptureBundle {
3739 schema: trace::CONTENT_CAPTURE_SCHEMA.to_string(),
3740 request_id: stale_id.clone(),
3741 created_unix_ms: 1_000,
3742 expires_unix_ms: 2_000, redacted: true,
3744 over_budget: false,
3745 body: Some(serde_json::json!({"old": true})),
3746 };
3747 let path = trace::controls::capture_path(&cap_dir, &stale_id);
3748 std::fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap();
3749 path
3750 };
3751
3752 let stale_path = plant_stale();
3754 let rt = Runtime::new().await.expect("runtime constructs");
3755 assert!(
3756 !stale_path.exists(),
3757 "Runtime startup must physically remove expired capture bundles"
3758 );
3759
3760 let stale_path = plant_stale();
3762 let _ = rt.shutdown_observability(std::time::Duration::ZERO);
3763 assert!(
3764 !stale_path.exists(),
3765 "sync shutdown epilogue must remove expired capture bundles"
3766 );
3767
3768 let stale_path = plant_stale();
3770 let _ = rt
3771 .shutdown_observability_async(std::time::Duration::from_millis(100))
3772 .await;
3773 assert!(
3774 !stale_path.exists(),
3775 "async shutdown epilogue must remove expired capture bundles"
3776 );
3777 drop(guard);
3778 }
3779
3780 #[tokio::test]
3785 #[serial_test::serial(synaps_base_dir)]
3786 async fn capture_root_binds_at_construction_and_survives_env_churn() {
3787 let base_a = tempfile::tempdir().unwrap();
3788 let base_b = tempfile::tempdir().unwrap();
3789 let old = std::env::var("SYNAPS_BASE_DIR").ok();
3790
3791 std::env::set_var("SYNAPS_BASE_DIR", base_a.path());
3792 let rt_a = Runtime::new().await.expect("runtime A");
3793 std::env::set_var("SYNAPS_BASE_DIR", base_b.path());
3794 let rt_b = Runtime::new().await.expect("runtime B");
3795
3796 let cap_a = base_a.path().join("trace").join("capture");
3797 let cap_b = base_b.path().join("trace").join("capture");
3798 let plant = |dir: &std::path::Path, name: &str| {
3799 agent_core::core::private_fs::ensure_private_dir(dir).unwrap();
3800 let id = trace::TraceId::new(name).unwrap();
3801 let stale = trace::controls::ContentCaptureBundle {
3802 schema: trace::CONTENT_CAPTURE_SCHEMA.to_string(),
3803 request_id: id.clone(),
3804 created_unix_ms: 1_000,
3805 expires_unix_ms: 2_000,
3806 redacted: true,
3807 over_budget: false,
3808 body: Some(serde_json::json!({"old": true})),
3809 };
3810 let path = trace::controls::capture_path(dir, &id);
3811 std::fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap();
3812 path
3813 };
3814
3815 let decoy = tempfile::tempdir().unwrap();
3817 std::env::set_var("SYNAPS_BASE_DIR", decoy.path());
3818
3819 let stale_a = plant(&cap_a, "req-stale-a");
3820 let stale_b = plant(&cap_b, "req-stale-b");
3821 let _ = rt_a.shutdown_observability(std::time::Duration::ZERO);
3822 assert!(
3823 !stale_a.exists(),
3824 "runtime A must sweep the root bound at ITS construction"
3825 );
3826 assert!(
3827 stale_b.exists(),
3828 "runtime A must never sweep another runtime's root"
3829 );
3830 let _ = rt_b
3831 .shutdown_observability_async(std::time::Duration::from_millis(100))
3832 .await;
3833 assert!(
3834 !stale_b.exists(),
3835 "runtime B must sweep the root bound at ITS construction"
3836 );
3837
3838 match old {
3839 Some(v) => std::env::set_var("SYNAPS_BASE_DIR", v),
3840 None => std::env::remove_var("SYNAPS_BASE_DIR"),
3841 }
3842 }
3843
3844 #[test]
3845 fn reload_rejects_policy_change_without_partial_apply() {
3846 let dir = tempfile::tempdir().unwrap();
3847 let path = dir.path().join("prompt.yaml");
3848 let manifest = |total: &str, content: &str| {
3849 format!(
3850 "schema: synaps-prompt/1\nkernel: kernel\nmodules:\n - id: kernel\n version: v1\n source: builtin\n priority: 0\n selectors: {{}}\n mutability: mutable_guidance\n content: {content}\npolicies:\n delegation:\n mode: enforced\n allowed_models: [anthropic/claude-sonnet-4-6]\n max_concurrent_workers: 1\n max_total_workers: {total}\n"
3851 )
3852 };
3853 std::fs::write(&path, manifest("1", "before")).unwrap();
3854 let parsed =
3855 agent_core::prompt::PromptManifest::parse(&std::fs::read_to_string(&path).unwrap())
3856 .unwrap();
3857 let model =
3858 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-sonnet-4-6").unwrap();
3859 let context = agent_core::prompt::SelectionContext::new(model.clone(), None).unwrap();
3860 let stack = agent_core::prompt::compile_prompt_stack(
3861 &parsed,
3862 &parsed.registry(path.parent()).unwrap(),
3863 &context,
3864 None,
3865 )
3866 .unwrap();
3867 let catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
3868 &model,
3869 parsed.delegation_catalog_candidates(),
3870 )
3871 .unwrap();
3872 let digest = parsed
3873 .delegation_policy(model, &catalog)
3874 .unwrap()
3875 .map(|p| p.digest());
3876 let mut runtime = Runtime::new_headless();
3877 runtime.apply_prompt_stack(stack).unwrap();
3878 runtime.retain_prompt_reload_source(path.clone(), context, None, digest.clone());
3879 let generation = runtime.prompt_generation();
3880 let composed = runtime.effective_prompt().unwrap().composed().to_owned();
3881
3882 std::fs::write(&path, manifest("2", "after")).unwrap();
3883 let error = runtime.reload_prompt().unwrap_err().to_string();
3884 assert!(error.contains("cannot safely change delegation policy"));
3885 assert_eq!(runtime.prompt_generation(), generation);
3886 assert_eq!(runtime.effective_prompt().unwrap().composed(), composed);
3887 assert_eq!(
3888 runtime
3889 .prompt_reload_source
3890 .as_ref()
3891 .unwrap()
3892 .delegation_policy_digest,
3893 digest
3894 );
3895 }
3896
3897 #[test]
3898 fn worker_install_does_not_replay_process_global_favorite_grants() {
3899 let foreground =
3900 crate::orchestration::canonical_foreground_identity("anthropic/claude-fable-5")
3901 .unwrap();
3902 let session = Arc::new(
3903 crate::orchestration::OrchestrationRuntime::baseline(foreground, 4, 8).unwrap(),
3904 );
3905 let favorite = "openai-codex/gpt-5.6-sol";
3906 assert!(
3907 session.preflight(favorite).is_err(),
3908 "precondition: the parent session did not grant this identity"
3909 );
3910
3911 let mut worker = Runtime::new_headless();
3912 worker.apply_config(&crate::config::SynapsConfig {
3913 favorite_models: vec![favorite.to_owned()],
3914 ..Default::default()
3915 });
3916 worker.install_worker_orchestration(Arc::clone(&session));
3917
3918 assert!(
3919 session.preflight(favorite).is_err(),
3920 "constructing a child must not mint/replay a favorite-model grant"
3921 );
3922 assert!(!session.effective_choices().contains(&favorite.to_owned()));
3923 }
3924
3925 #[test]
3930 #[serial_test::serial(synaps_base_dir)]
3931 fn tool_spill_follows_config_and_is_keyed_by_the_conversation() {
3932 let base = crate::test_env::BaseDirGuard::new();
3933 let mut runtime = Runtime::new_headless();
3934 assert!(!runtime.tool_spill_policy().is_enabled());
3935 assert!(!runtime.tool_spill().is_enabled());
3936 runtime.apply_config(&crate::config::SynapsConfig::default());
3937 assert_eq!(
3938 runtime.tool_spill_policy(),
3939 tool_spill::ToolSpillPolicy {
3940 threshold_bytes: 12 * 1024,
3941 head_bytes: 4 * 1024,
3942 tail_bytes: 3 * 1024,
3943 }
3944 );
3945 runtime.set_session_id(Some("20261029-120000-ab12".into()));
3946 assert_eq!(
3947 runtime.tool_spill().dir(),
3948 Some(base.path().join("spill").join("20261029-120000-ab12").as_path())
3949 );
3950 let clone = runtime.clone();
3952 let text = "line\n".repeat(4_000);
3953 let virtualised = clone
3954 .tool_spill()
3955 .virtualise("toolu_1", &tool_spill::SpillTarget::Spill, &text)
3956 .expect("over the threshold");
3957 assert_eq!(runtime.tool_spill_totals().virtualised, 1);
3958 assert_eq!(
3959 runtime.tool_spill_totals().kept_out_bytes(),
3960 (text.len() - virtualised.preview.len()) as u64
3961 );
3962 runtime.apply_config(&agent_core::config::load_config_from_str(
3963 "tool_output_spill_bytes = 0\n",
3964 ));
3965 assert!(!runtime.tool_spill().is_enabled(), "0 turns it off");
3966 }
3967
3968 #[test]
3969 fn read_dedupe_is_off_until_configured_then_follows_config() {
3970 let mut runtime = Runtime::new_headless();
3971 assert!(!runtime.read_dedupe(), "off until a config is applied");
3972 runtime.apply_config(&agent_core::config::load_config_from_str(""));
3973 assert!(runtime.read_dedupe(), "the config default is on");
3974 assert!(runtime.clone().read_dedupe(), "clones keep it");
3975 runtime.apply_config(&agent_core::config::load_config_from_str("read_dedupe = off\n"));
3976 assert!(!runtime.read_dedupe());
3977 }
3978
3979 #[test]
3983 fn thinking_display_is_foreground_only() {
3984 use crate::config::ThinkingDisplay;
3985 use crate::runtime::openai::catalog::CodexRequestRole;
3986 let mut runtime = Runtime::new_headless();
3987 assert_eq!(runtime.wire_thinking_display(), ThinkingDisplay::Summarized);
3988 runtime.apply_config(&agent_core::config::load_config_from_str("thinking_display = omitted\n"));
3989 assert_eq!(runtime.thinking_display(), ThinkingDisplay::Omitted);
3990 assert_eq!(runtime.wire_thinking_display(), ThinkingDisplay::Omitted);
3991 assert_eq!(runtime.clone().wire_thinking_display(), ThinkingDisplay::Omitted);
3992
3993 let mut worker = runtime.clone();
3995 worker.set_codex_request_role(CodexRequestRole::Worker);
3996 assert_eq!(worker.thinking_display(), ThinkingDisplay::Omitted, "config kept");
3997 assert_eq!(worker.wire_thinking_display(), ThinkingDisplay::Summarized, "wire forced");
3998 let mut internal = runtime.clone();
3999 internal.set_codex_request_role(CodexRequestRole::Internal);
4000 assert_eq!(internal.wire_thinking_display(), ThinkingDisplay::Summarized);
4001
4002 runtime.apply_config(&agent_core::config::load_config_from_str(""));
4003 assert_eq!(runtime.wire_thinking_display(), ThinkingDisplay::Summarized, "default");
4004 }
4005
4006 #[test]
4007 fn tool_keepwarm_is_off_until_configured_then_follows_config() {
4008 let mut runtime = Runtime::new_headless();
4009 assert!(!runtime.tool_keepwarm(), "off until a config is applied");
4010 runtime.apply_config(&agent_core::config::load_config_from_str(""));
4011 assert!(runtime.tool_keepwarm(), "the config default is on");
4012 assert!(runtime.clone().tool_keepwarm(), "clones keep it");
4013 runtime.apply_config(&agent_core::config::load_config_from_str(
4014 "cache_keepwarm_tools = off\n",
4015 ));
4016 assert!(!runtime.tool_keepwarm());
4017 }
4018
4019 #[test]
4020 fn host_install_may_apply_explicit_operator_favorites() {
4021 let mut runtime = Runtime::new_headless();
4022 let config = crate::config::SynapsConfig {
4023 favorite_models: vec![
4024 "openai-codex/gpt-5.6-luna".to_owned(),
4025 "anthropic/claude-opus-4-6".to_owned(),
4026 "claude/claude-fable-5".to_owned(),
4028 "not-qualified".to_owned(),
4030 ],
4031 ..Default::default()
4032 };
4033 runtime.apply_config(&config);
4034 let foreground =
4035 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
4036 runtime.install_orchestration(std::sync::Arc::new(
4037 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap(),
4038 ));
4039
4040 let orchestration = runtime.orchestration().unwrap();
4041 for trusted in [
4042 "openai-codex/gpt-5.6-sol",
4043 "openai-codex/gpt-5.6-luna",
4044 "anthropic/claude-opus-4-6",
4045 "anthropic/claude-fable-5",
4046 ] {
4047 assert!(
4048 orchestration
4049 .effective_choices()
4050 .contains(&trusted.to_owned()),
4051 "missing configured worker choice {trusted}: {:?}",
4052 orchestration.effective_choices()
4053 );
4054 }
4055 let authorized = orchestration
4056 .resolve_and_authorize("sa_cross_provider", Some("anthropic/claude-opus-4-6"))
4057 .expect("an explicitly favorited cross-provider model must be authorized");
4058 assert_eq!(authorized.model.as_str(), "anthropic/claude-opus-4-6");
4059 assert!(!orchestration
4060 .effective_choices()
4061 .contains(&"not-qualified".to_owned()));
4062 }
4063
4064 #[test]
4065 fn configured_worker_choices_survive_manifestless_foreground_changes() {
4066 let mut runtime = Runtime::new_headless();
4067 runtime.apply_config(&crate::config::SynapsConfig {
4068 favorite_models: vec!["anthropic/claude-opus-4-6".to_owned()],
4069 ..Default::default()
4070 });
4071 let foreground =
4072 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
4073 runtime.install_orchestration(std::sync::Arc::new(
4074 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap(),
4075 ));
4076
4077 runtime
4078 .try_set_model("xai-auth/grok-4.5-latest".to_owned())
4079 .unwrap();
4080
4081 let orchestration = runtime.orchestration().unwrap();
4082 assert_eq!(orchestration.foreground_model(), "xai-auth/grok-4.5-latest");
4083 assert!(orchestration
4084 .effective_choices()
4085 .contains(&"anthropic/claude-opus-4-6".to_owned()));
4086 orchestration
4087 .resolve_and_authorize("sa_after_switch", Some("anthropic/claude-opus-4-6"))
4088 .expect("configured worker trust must survive policy replacement");
4089 }
4090
4091 #[test]
4092 fn configured_subagent_limits_size_the_policy_rebuilt_on_model_change() {
4093 let mut runtime = Runtime::new_headless();
4094 assert_eq!(
4095 runtime.subagent_limits(),
4096 crate::config::SubagentLimits::default(),
4097 "defaults before any config"
4098 );
4099 runtime.apply_config(&crate::config::SynapsConfig {
4100 subagent: crate::config::SubagentLimits {
4101 max_concurrent: 24,
4102 max_total: 96,
4103 max_depth: 2,
4104 },
4105 ..Default::default()
4106 });
4107 let foreground =
4108 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
4109 runtime.install_orchestration(std::sync::Arc::new(
4110 crate::orchestration::OrchestrationRuntime::from_limits(
4111 foreground,
4112 runtime.subagent_limits(),
4113 )
4114 .unwrap(),
4115 ));
4116 runtime
4117 .try_set_model("xai-auth/grok-4.5-latest".to_owned())
4118 .unwrap();
4119
4120 let orchestration = runtime.orchestration().unwrap();
4121 assert_eq!(orchestration.foreground_model(), "xai-auth/grok-4.5-latest");
4122 assert_eq!(
4123 orchestration.ultracode_readiness("xai-auth/grok-4.5-latest"),
4124 Ok((24, 96)),
4125 "the replacement policy uses the configured limits, not a hardcoded 8/64"
4126 );
4127 assert_eq!(orchestration.reserve_delegation("a", None), Ok(1));
4128 assert_eq!(orchestration.reserve_delegation("b", Some("a")), Ok(2));
4129 assert_eq!(
4130 orchestration.reserve_delegation("c", Some("b")),
4131 Err(crate::orchestration::DelegationTreeDenied::DepthLimit { max_depth: 2 })
4132 );
4133 }
4134
4135 #[test]
4136 fn set_model_replaces_manifestless_orchestration_foreground_snapshot() {
4137 let mut runtime = Runtime::new_headless();
4138 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4139 runtime.install_orchestration(std::sync::Arc::new(
4140 crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
4141 ));
4142
4143 runtime.set_model("openrouter/deepseek/deepseek-v4-pro".to_owned());
4144
4145 let orchestration = runtime.orchestration().unwrap();
4146 assert_eq!(
4147 orchestration.foreground_model(),
4148 "openrouter/deepseek/deepseek-v4-pro"
4149 );
4150 assert!(orchestration
4151 .effective_choices()
4152 .contains(&"openrouter/moonshotai/kimi-k2.7-code".to_owned()));
4153 assert!(orchestration
4154 .resolve_and_authorize("sa_kimi", Some("openrouter/moonshotai/kimi-k2.7-code"))
4155 .is_ok());
4156 }
4157
4158 #[test]
4163 fn set_model_to_uncached_muse_id_rebuilds_orchestration() {
4164 let _guard = crate::runtime::openai::catalog::muse::test_cache_guard();
4165 crate::runtime::openai::catalog::capability_cache::replace_provider("muse", &[]);
4166 let mut runtime = Runtime::new_headless();
4167 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4168 runtime.install_orchestration(std::sync::Arc::new(
4169 crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
4170 ));
4171
4172 for model in ["muse/muse-wonder-1.0", "muse/muse-live-only-zz5"] {
4173 runtime
4174 .try_set_model(model.to_owned())
4175 .unwrap_or_else(|e| panic!("{model}: {e}"));
4176 assert_eq!(runtime.model(), model);
4177 assert_eq!(runtime.orchestration().unwrap().foreground_model(), model);
4178 }
4179 assert!(runtime.try_set_model("muse/Bad Id".to_owned()).is_err());
4181 assert_eq!(runtime.model(), "muse/muse-live-only-zz5");
4182 }
4183
4184 #[test]
4185 fn set_model_refuses_to_orphan_unreconciled_policy_workers() {
4186 let mut runtime = Runtime::new_headless();
4187 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4188 let orchestration = std::sync::Arc::new(
4189 crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
4190 );
4191 orchestration
4192 .resolve_and_authorize("sa_active", None)
4193 .unwrap();
4194 runtime.install_orchestration(orchestration.clone());
4195 runtime.model = "openrouter/z-ai/glm-5.2".to_owned();
4196
4197 let error = runtime
4198 .try_set_model("openrouter/deepseek/deepseek-v4-pro".to_owned())
4199 .unwrap_err();
4200
4201 assert!(error.contains("collection/reconciliation"));
4202 assert_eq!(runtime.model(), "openrouter/z-ai/glm-5.2");
4203 assert!(std::sync::Arc::ptr_eq(
4204 runtime.orchestration().unwrap(),
4205 &orchestration
4206 ));
4207 assert!(orchestration.is_unreconciled("sa_active"));
4208 }
4209
4210 #[test]
4211 fn set_model_keeps_active_typed_manifest_orchestration_unchanged() {
4212 let dir = tempfile::tempdir().unwrap();
4213 let manifest_path = dir.path().join("prompt.yaml");
4214 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4215 let mut runtime = Runtime::new_headless();
4216 let orchestration = std::sync::Arc::new(
4217 crate::orchestration::OrchestrationRuntime::baseline(glm.clone(), 3, 8).unwrap(),
4218 );
4219 runtime.install_orchestration(orchestration.clone());
4220 runtime.retain_prompt_reload_source(
4221 manifest_path,
4222 agent_core::prompt::SelectionContext::new(glm, None).unwrap(),
4223 None,
4224 None,
4225 );
4226
4227 runtime.set_model("openrouter/deepseek/deepseek-v4-pro".to_owned());
4228
4229 assert!(std::sync::Arc::ptr_eq(
4230 runtime.orchestration().unwrap(),
4231 &orchestration
4232 ));
4233 assert_eq!(
4234 runtime.orchestration().unwrap().foreground_model(),
4235 "openrouter/z-ai/glm-5.2"
4236 );
4237 }
4238
4239 #[test]
4240 fn set_model_preserves_qualified_provider_ids_and_bare_claude() {
4241 let cases = [
4242 (
4243 "github-copilot/claude-opus-4.8",
4244 "github-copilot/claude-opus-4.8",
4245 ),
4246 ("anthropic/claude-sonnet-4-6", "anthropic/claude-sonnet-4-6"),
4247 ("google/gemini-2.5-pro", "google/gemini-2.5-pro"),
4248 ("openai/gpt-5", "openai/gpt-5"),
4249 ("claude-opus-4-8", "claude-opus-4-8"),
4250 ];
4251
4252 for (input, expected) in cases {
4253 let mut runtime = Runtime::new_headless();
4254 runtime.set_model(input.to_owned());
4255 assert_eq!(runtime.model(), expected, "input: {input}");
4256 }
4257 }
4258
4259 #[test]
4260 fn set_model_strips_only_legacy_health_status_decoration() {
4261 let cases = [
4262 ("✅ 339ms groq/llama-3.3-70b", "groq/llama-3.3-70b"),
4263 (
4264 "✅ 339ms github-copilot/claude-opus-4.8",
4265 "github-copilot/claude-opus-4.8",
4266 ),
4267 (
4268 "⚠️ 1200ms anthropic/claude-sonnet-4-6",
4269 "anthropic/claude-sonnet-4-6",
4270 ),
4271 ];
4272
4273 for (input, expected) in cases {
4274 let mut runtime = Runtime::new_headless();
4275 runtime.set_model(input.to_owned());
4276 assert_eq!(runtime.model(), expected, "input: {input}");
4277 }
4278 }
4279
4280 #[tokio::test]
4281 async fn confirm_without_prompt_fails_closed() {
4282 let result = resolve_before_tool_call_result(
4283 crate::extensions::hooks::events::HookResult::confirm("Run deploy?"),
4284 None,
4285 false,
4286 None,
4287 )
4288 .await;
4289
4290 assert!(matches!(
4291 result,
4292 crate::extensions::hooks::events::HookResult::Block { reason }
4293 if reason.contains("requires confirmation") && reason.contains("Run deploy?")
4294 ));
4295 }
4296
4297 #[tokio::test]
4298 async fn modify_result_replaces_tool_input() {
4299 let result = resolve_before_tool_call_decision(
4300 serde_json::json!({"command":"rm -rf /"}),
4301 crate::extensions::hooks::events::HookResult::Modify {
4302 input: serde_json::json!({"command":"echo safe"}),
4303 },
4304 None,
4305 false,
4306 None,
4307 )
4308 .await;
4309
4310 match result {
4311 BeforeToolCallDecision::Continue { input } => {
4312 assert_eq!(input, serde_json::json!({"command":"echo safe"}));
4313 }
4314 BeforeToolCallDecision::Block { reason } => panic!("unexpected block: {reason}"),
4315 }
4316 }
4317
4318 #[tokio::test]
4319 async fn confirm_prompt_yes_continues() {
4320 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4321 let handle = crate::tools::SecretPromptHandle::new(tx);
4322
4323 let task = tokio::spawn(async move {
4324 let request = rx.recv().await.expect("confirm prompt request");
4325 assert_eq!(request.title, "Confirm tool call");
4326 assert!(request.prompt.contains("Run deploy?"));
4327 let _ = request.response_tx.send(Some("yes".to_string()));
4328 });
4329
4330 let result = resolve_before_tool_call_result(
4331 crate::extensions::hooks::events::HookResult::confirm("Run deploy?"),
4332 Some(&handle),
4333 false,
4334 None,
4335 )
4336 .await;
4337
4338 task.await.unwrap();
4339 assert!(matches!(
4340 result,
4341 crate::extensions::hooks::events::HookResult::Continue
4342 ));
4343 }
4344
4345 #[tokio::test]
4346 async fn confirm_prompt_non_yes_blocks() {
4347 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4348 let handle = crate::tools::SecretPromptHandle::new(tx);
4349
4350 let task = tokio::spawn(async move {
4351 let request = rx.recv().await.expect("confirm prompt request");
4352 let _ = request.response_tx.send(Some("no".to_string()));
4353 });
4354
4355 let result = resolve_before_tool_call_result(
4356 crate::extensions::hooks::events::HookResult::confirm("Run deploy?"),
4357 Some(&handle),
4358 false,
4359 None,
4360 )
4361 .await;
4362
4363 task.await.unwrap();
4364 assert!(matches!(
4365 result,
4366 crate::extensions::hooks::events::HookResult::Block { reason }
4367 if reason.contains("confirmation denied")
4368 ));
4369 }
4370
4371 fn ext(ids: &[&str]) -> Vec<String> {
4372 ids.iter().map(|s| s.to_string()).collect()
4373 }
4374
4375 fn key(ids: &[&str]) -> Vec<crate::tools::LatchKey> {
4376 ids.iter().map(|s| (s.to_string(), 0)).collect()
4377 }
4378
4379 fn sourced_confirm(
4380 message: &str,
4381 source: &str,
4382 tool: &str,
4383 ) -> crate::extensions::hooks::events::HookResult {
4384 crate::extensions::hooks::events::HookResult::Confirm {
4385 message: message.into(),
4386 sources: ext(&[source]),
4387 tool: Some(tool.into()),
4388 generations: vec![],
4389 modified_input: None,
4390 modified_by: vec![],
4391 }
4392 }
4393
4394 #[test]
4395 fn session_allow_all_clones_share_but_independent_runtimes_do_not() {
4396 use std::sync::Arc;
4397 let parent = Runtime::new_headless();
4398 let cloned = parent.clone();
4399 let independent = Runtime::new_headless();
4400 assert!(Arc::ptr_eq(
4401 parent.session_allow_all(),
4402 cloned.session_allow_all()
4403 ));
4404 assert!(!Arc::ptr_eq(
4405 parent.session_allow_all(),
4406 independent.session_allow_all()
4407 ));
4408 cloned.session_allow_all().allow(&key(&["jev"]));
4409 assert!(parent.session_allow_all().allows_all(&key(&["jev"])));
4410 assert!(!independent.session_allow_all().allows_all(&key(&["jev"])));
4411 }
4412
4413 #[test]
4414 fn session_allow_all_conversation_boundary_isolates_old_clones() {
4415 use std::sync::Arc;
4416 let mut runtime = Runtime::new_headless();
4417 let old_worker = runtime.clone();
4418 old_worker.session_allow_all().allow(&key(&["jev"]));
4419
4420 runtime.begin_conversation(Some("next".into()));
4421 assert_eq!(runtime.session_id(), Some("next"));
4422 assert!(runtime.session_allow_all().is_empty());
4423 assert!(!Arc::ptr_eq(
4424 runtime.session_allow_all(),
4425 old_worker.session_allow_all()
4426 ));
4427 assert!(
4430 old_worker.session_allow_all().is_empty(),
4431 "old latch must be cleared, not just replaced"
4432 );
4433 assert!(!old_worker.session_allow_all().allow(&key(&["jev"])));
4436 assert!(old_worker.session_allow_all().is_empty());
4437 assert!(runtime.session_allow_all().is_empty());
4438
4439 let new_worker = runtime.clone();
4440 new_worker.session_allow_all().allow(&key(&["jev"]));
4441 assert!(runtime.session_allow_all().allows_all(&key(&["jev"])));
4442 runtime.begin_conversation(Some("next".into()));
4444 assert!(runtime.session_allow_all().is_empty());
4445 assert!(!Arc::ptr_eq(
4446 runtime.session_allow_all(),
4447 new_worker.session_allow_all()
4448 ));
4449 }
4450
4451 #[test]
4452 fn conversation_boundary_resets_the_cache_diagnosis_chain() {
4453 let mut runtime = Runtime::new_headless();
4454 runtime.set_cache_diagnostics(true);
4455 let mut driver = runtime.clone();
4456 driver.share_ttl_latches(&runtime);
4457 runtime.cache_diagnosis().unwrap().record("msg_old");
4458 runtime.set_session_id(Some("successor".into()));
4460 assert_eq!(
4461 runtime
4462 .cache_diagnosis()
4463 .unwrap()
4464 .previous_message_id()
4465 .as_deref(),
4466 Some("msg_old")
4467 );
4468 runtime.begin_conversation(Some("next".into()));
4470 assert_eq!(
4471 runtime.cache_diagnosis().unwrap().previous_message_id(),
4472 None
4473 );
4474 assert_eq!(
4475 driver.cache_diagnosis().unwrap().previous_message_id(),
4476 None
4477 );
4478 }
4479
4480 #[test]
4481 fn session_allow_all_identity_change_preserves_compaction_consent() {
4482 use std::sync::Arc;
4483 let mut runtime = Runtime::new_headless();
4484 let worker = runtime.clone();
4485 worker.session_allow_all().allow(&key(&["jev"]));
4486 runtime.set_session_id(Some("linked-successor".into()));
4487 assert!(Arc::ptr_eq(
4488 runtime.session_allow_all(),
4489 worker.session_allow_all()
4490 ));
4491 assert!(runtime.session_allow_all().allows_all(&key(&["jev"])));
4492 }
4493
4494 #[tokio::test]
4498 async fn confirm_prompt_always_latches_only_the_source_extension() {
4499 let latch = crate::tools::SessionConfirmLatch::new();
4500 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4501 let handle = crate::tools::SecretPromptHandle::new(tx);
4502
4503 let task = tokio::spawn(async move {
4504 let first = rx.recv().await.expect("first confirm prompt");
4505 assert_eq!(
4507 first.confirm_scope,
4508 Some(crate::session::ConfirmScope {
4509 extensions: vec!["jev".into()],
4510 tool: Some("bash".into()),
4511 rewritten_by: vec![],
4512 })
4513 );
4514 assert!(first.prompt.starts_with("Extension: jev\nTool: bash\n"), "{}", first.prompt);
4515 let _ = first
4516 .response_tx
4517 .send(Some(CONFIRM_ANSWER_ALLOW_ALL.to_string()));
4518 let other = rx.recv().await.expect("other extension still prompts");
4521 assert_eq!(
4522 other.confirm_scope.as_ref().map(|s| s.extensions.clone()),
4523 Some(vec!["other".to_string()])
4524 );
4525 let _ = other.response_tx.send(None);
4526 assert!(rx.recv().await.is_none(), "no further prompts");
4527 });
4528
4529 let first = resolve_before_tool_call_result(
4530 sourced_confirm("Run deploy?", "jev", "bash"),
4531 Some(&handle),
4532 false,
4533 Some(&latch),
4534 )
4535 .await;
4536 assert!(matches!(
4537 first,
4538 crate::extensions::hooks::events::HookResult::Continue
4539 ));
4540 assert_eq!(latch.extensions(), ext(&["jev"]), "latch keyed by source");
4541
4542 let second = resolve_before_tool_call_result(
4543 sourced_confirm("Run deploy again?", "jev", "write"),
4544 Some(&handle),
4545 false,
4546 Some(&latch),
4547 )
4548 .await;
4549 assert!(matches!(
4550 second,
4551 crate::extensions::hooks::events::HookResult::Continue
4552 ));
4553
4554 let other = resolve_before_tool_call_result(
4555 sourced_confirm("Run ls?", "other", "bash"),
4556 Some(&handle),
4557 false,
4558 Some(&latch),
4559 )
4560 .await;
4561 assert!(
4562 matches!(other, crate::extensions::hooks::events::HookResult::Block { .. }),
4563 "another extension's confirm is not covered by jev's latch"
4564 );
4565 drop(handle);
4566 task.await.unwrap();
4567 }
4568
4569 #[tokio::test]
4572 async fn latched_plus_unlatched_source_prompts_and_allow_all_latches_both() {
4573 let latch = crate::tools::SessionConfirmLatch::new();
4574 latch.allow(&key(&["a"]));
4575 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4576 let handle = crate::tools::SecretPromptHandle::new(tx);
4577 let task = tokio::spawn(async move {
4578 let req = rx.recv().await.expect("must prompt: b is not latched");
4579 assert_eq!(
4580 req.confirm_scope.as_ref().map(|s| s.extensions.clone()),
4581 Some(vec!["a".to_string(), "b".to_string()])
4582 );
4583 assert!(req.prompt.starts_with("Extension: a, b\n"), "{}", req.prompt);
4584 let _ = req.response_tx.send(Some(CONFIRM_ANSWER_ALLOW_ALL.to_string()));
4585 });
4586 let merged = || crate::extensions::hooks::events::HookResult::Confirm {
4587 message: "[a] x\n\n[b] y".into(),
4588 sources: ext(&["a", "b"]),
4589 generations: vec![],
4590 tool: Some("bash".into()),
4591 modified_input: None,
4592 modified_by: vec![],
4593 };
4594 let first = resolve_before_tool_call_result(merged(), Some(&handle), false, Some(&latch)).await;
4595 task.await.unwrap();
4596 assert!(matches!(first, crate::extensions::hooks::events::HookResult::Continue));
4597 assert!(latch.allows_all(&key(&["a", "b"])));
4598 drop(handle);
4600 let second = resolve_before_tool_call_result(merged(), None, false, Some(&latch)).await;
4601 assert!(matches!(second, crate::extensions::hooks::events::HookResult::Continue));
4602 }
4603
4604 #[tokio::test]
4607 async fn no_latch_means_no_allow_all_scope() {
4608 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4609 let handle = crate::tools::SecretPromptHandle::new(tx);
4610 let task = tokio::spawn(async move {
4611 let req = rx.recv().await.unwrap();
4612 assert!(req.confirm_scope.is_none());
4613 let _ = req.response_tx.send(Some("y".into()));
4614 });
4615 let r = resolve_before_tool_call_result(
4616 sourced_confirm("Run?", "jev", "bash"),
4617 Some(&handle),
4618 false,
4619 None,
4620 )
4621 .await;
4622 task.await.unwrap();
4623 assert!(matches!(r, crate::extensions::hooks::events::HookResult::Continue));
4624 }
4625
4626 #[tokio::test]
4631 async fn rewritten_input_is_shown_in_the_confirm_prompt() {
4632 let latch = crate::tools::SessionConfirmLatch::new();
4633 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4634 let handle = crate::tools::SecretPromptHandle::new(tx);
4635 let task = tokio::spawn(async move {
4636 let req = rx.recv().await.unwrap();
4637 let body = req.prompt.clone();
4638 let scope = req.confirm_scope.clone().unwrap();
4639 let _ = req.response_tx.send(Some("y".into()));
4640 (body, scope)
4641 });
4642 let result = resolve_before_tool_call_result(
4643 crate::extensions::hooks::events::HookResult::Confirm {
4644 message: "Run this bash command?".into(),
4645 sources: ext(&["jev"]),
4646 generations: vec![],
4647 tool: Some("bash".into()),
4648 modified_input: Some(serde_json::json!({"command": "rm -rf build/"})),
4649 modified_by: ext(&["rewriter"]),
4650 },
4651 Some(&handle),
4652 false,
4653 Some(&latch),
4654 )
4655 .await;
4656 let (body, scope) = task.await.unwrap();
4657 let header_end = body.find("Message from jev:\nRun this bash command?").unwrap();
4658 let header = &body[..header_end];
4659 assert!(header.contains("Input rewritten by: rewriter"), "{body}");
4660 assert!(header.contains("Input that will run:\n rm -rf build/"), "{body}");
4661 assert_eq!(scope.rewritten_by, ext(&["rewriter"]));
4662 assert!(matches!(
4663 result,
4664 crate::extensions::hooks::events::HookResult::Modify { input }
4665 if input == serde_json::json!({"command": "rm -rf build/"})
4666 ));
4667 let plain = confirm_prompt_body("m", &ext(&["jev"]), Some("bash"), None);
4669 assert!(!plain.contains("rewritten"), "{plain}");
4670 }
4671
4672 #[test]
4674 fn final_input_preview_redacts_credentials() {
4675 let secrets = [
4676 "SECRET-BEARER-123",
4677 "SECRET-KEY-456",
4678 "SECRET-FIELD-789",
4679 "QmFzaWNTRUNSRVQ=",
4680 "SECRET-TOK-000",
4681 "SECRET-FLAG-111",
4682 "sk-ant-api03-abcdefghijklmnop",
4683 "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl",
4684 "ghp_abcdefghijklmnopqrstuvwxyz0123",
4685 ];
4686 let command = serde_json::json!({
4687 "command": "curl -H 'Authorization: Bearer SECRET-BEARER-123' \
4688 'https://api.example/v1?api_key=SECRET-KEY-456' \
4689 -H \"authorization: Basic QmFzaWNTRUNSRVQ=\" \
4690 && TOKEN=SECRET-TOK-000 deploy --password SECRET-FLAG-111 \
4691 sk-ant-api03-abcdefghijklmnop \
4692 eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl \
4693 ghp_abcdefghijklmnopqrstuvwxyz0123"
4694 });
4695 let json = serde_json::json!({
4696 "url": "https://x/y",
4697 "api_key": "SECRET-FIELD-789",
4698 "headers": {"X-Note": "Bearer SECRET-BEARER-123"},
4699 "args": ["--token=SECRET-TOK-000"],
4700 });
4701 for input in [command, json] {
4702 let p = final_input_preview(&input);
4703 for secret in secrets {
4704 assert!(!p.contains(secret), "{secret} leaked in:\n{p}");
4705 }
4706 assert!(p.contains("[REDACTED]"), "{p}");
4707 }
4708 let plain = final_input_preview(&serde_json::json!({"command": "rm -rf build/ && ls -la"}));
4710 assert!(plain.contains("rm -rf build/ && ls -la"), "{plain}");
4711 }
4712
4713 #[test]
4716 fn redact_secret_text_covers_round4_shapes() {
4717 let rows: &[(&str, &str)] = &[
4718 ("curl -H 'x-api-key: XAPIKEY-SECRET-1' https://a", "XAPIKEY-SECRET-1"),
4719 ("FOO_TOKEN=TOK-SECRET-2 run", "TOK-SECRET-2"),
4720 ("MY_SECRET=SEC-SECRET-3 ./x", "SEC-SECRET-3"),
4721 ("DB_PASSWORD=PW-SECRET-4 ./x", "PW-SECRET-4"),
4722 ("OPENAI_API_KEY=APIKEY-SECRET-5 ./x", "APIKEY-SECRET-5"),
4723 ("curl -H 'Authorization: Basic QkFTSUMtU0VDUkVULTY='", "QkFTSUMtU0VDUkVULTY="),
4724 ("aws_secret_access_key=AWSSEC-SECRET-7", "AWSSEC-SECRET-7"),
4725 ("git clone https://alice:USERINFO-SECRET-8@github.com/o/r", "USERINFO-SECRET-8"),
4726 ("psql postgres://app:PGPW-SECRET-9@db:5432/prod", "PGPW-SECRET-9"),
4727 ("DATABASE_URL=postgres://app:DBURL-SECRET-10@db/prod ./run", "DBURL-SECRET-10"),
4728 ("curl -H 'Cookie: sid=COOKIE-SECRET-11; theme=dark' https://a", "COOKIE-SECRET-11"),
4729 ("printf 'Set-Cookie: sid=SETCOOKIE-SECRET-12'", "SETCOOKIE-SECRET-12"),
4730 ("curl -u alice:CURLU-SECRET-13 https://a", "CURLU-SECRET-13"),
4731 ("mysql -u root -pMYSQL-SECRET-14 prod", "MYSQL-SECRET-14"),
4732 ("sshpass -p SSHPASS-SECRET-15 ssh host", "SSHPASS-SECRET-15"),
4733 ("aws configure set aws_secret_access_key AWSCFG-SECRET-16", "AWSCFG-SECRET-16"),
4734 ("aws x --secret-access-key FLAG-SECRET-17 --region eu", "FLAG-SECRET-17"),
4735 ("aws x --access-key FLAG-SECRET-18", "FLAG-SECRET-18"),
4736 ("STRIPE_KEY=rk_live_STRIPE-SECRET-19 node app", "STRIPE-SECRET-19"),
4737 ("OPENAI_KEY=OAIKEY-SECRET-20 node app", "OAIKEY-SECRET-20"),
4738 ];
4739 let precise = [
4742 "QkFTSUMtU0VDUkVULTY=",
4743 "USERINFO-SECRET-8",
4744 "PGPW-SECRET-9",
4745 "DBURL-SECRET-10",
4746 "COOKIE-SECRET-11",
4747 "SETCOOKIE-SECRET-12",
4748 "CURLU-SECRET-13",
4749 "MYSQL-SECRET-14",
4750 "SSHPASS-SECRET-15",
4751 ];
4752 for (text, secret) in rows {
4753 let out = redact_secret_text(text);
4755 assert!(!out.contains(secret), "{secret} leaked: {out}");
4756 assert!(out.contains("[REDACTED]"), "{out}");
4757 let preview = final_input_preview(&serde_json::json!({ "command": text }));
4758 assert!(!preview.contains(secret), "{secret} leaked in preview: {preview}");
4759 if precise.contains(secret) {
4760 let mut v = serde_json::Value::String(text.to_string());
4761 crate::runtime::trace::export::redact_value(&mut v);
4762 assert!(
4763 !v.as_str().unwrap().contains(secret),
4764 "shared scrub leaked {secret}: {v}"
4765 );
4766 }
4767 }
4768 for ok in [
4770 "mkdir -p build/out",
4771 "ssh -p 2222 host",
4772 "ssh -p2222 host",
4773 "docker run -p 8080:80 nginx",
4774 "mysql -h db -P 3306 -p prod",
4775 "curl -u alice https://example.com/path",
4776 "echo the token is valid and the password field is empty",
4777 "monkey=1 make",
4778 "git log --format=%H -- src/",
4779 "https://example.com/a/b?page=2",
4780 ] {
4781 assert_eq!(redact_secret_text(ok), ok, "over-redacted");
4782 }
4783 }
4784
4785 #[test]
4790 fn shared_scrub_leaves_benign_code_and_prose_alone() {
4791 let benign = [
4792 "max_tokens: 4096",
4793 "password: String,",
4794 "let token = load()?;",
4795 "the password-reset flow",
4796 "pub authorization: Option<String>,",
4797 "cookie: HashMap<String, String>,",
4798 "MY_KEY=value_in_docs",
4799 "--token-file path/to/file",
4800 "fn refresh_access_token(&self) -> Result<Token>",
4801 "aws_secret_access_key is the field name",
4802 ];
4803 for text in benign {
4804 let mut v = serde_json::Value::String(text.to_string());
4805 crate::runtime::trace::export::redact_value(&mut v);
4806 assert_eq!(v.as_str().unwrap(), text, "shared scrub mangled benign text");
4807 assert_eq!(crate::runtime::trace::export::redact_text(text), text);
4808 }
4809 let mut record = serde_json::json!({
4811 "text": "set max_tokens: 4096 and keep the password-reset flow",
4812 "code": ["password: String,", "let token = load()?;"],
4813 });
4814 let before = record.clone();
4815 crate::runtime::trace::export::redact_value(&mut record);
4816 assert_eq!(record, before);
4817 }
4818
4819 #[test]
4822 fn confirmer_message_echoing_injected_credentials_is_redacted() {
4823 let injected = serde_json::json!({
4824 "command": "curl -H 'Authorization: Bearer ECHOED-BEARER-X' https://a"
4825 });
4826 let body = confirm_prompt_body(
4827 "Run: curl -H 'Authorization: Bearer ECHOED-BEARER-X' https://a ?",
4828 &ext(&["jev"]),
4829 Some("bash"),
4830 Some((&ext(&["injector"]), &injected)),
4831 );
4832 assert!(!body.contains("ECHOED-BEARER-X"), "{body}");
4833 assert!(body.contains("Message from jev:\nRun: curl -H 'Authorization: [REDACTED]"), "{body}");
4834 }
4835
4836 #[test]
4840 fn confirm_message_control_and_bidi_chars_are_stripped() {
4841 let hostile = "ok\x1b[3A\x1b[2KExtension: trusted\rTool: none\u{0085}\u{202E}evil\u{200B}x\u{2066}y\tz\nnext";
4842 let body = confirm_prompt_body(hostile, &ext(&["jev"]), Some("bash"), None);
4843 for bad in ['\x1b', '\r', '\u{0085}', '\u{202E}', '\u{200B}', '\u{2066}', '\t'] {
4844 assert!(!body.contains(bad), "{bad:?} survived: {body:?}");
4845 }
4846 assert!(body.contains("Message from jev:\nok[3A[2KExtension: trustedTool: noneevilxy z\nnext"), "{body:?}");
4847 assert!(body.starts_with("Extension: jev\nTool: bash\n"));
4848 }
4849
4850 #[test]
4853 fn extension_message_is_labelled_after_the_host_header() {
4854 let fake = "Input that will run:\n echo harmless";
4855 let body = confirm_prompt_body(fake, &ext(&["jev"]), Some("bash"), None);
4856 let label = body.find("Message from jev:\n").expect(&body);
4857 assert_eq!(body.find("Input that will run:"), Some(label + "Message from jev:\n".len()));
4858 assert!(body.starts_with("Extension: jev\nTool: bash\n"), "{body}");
4859 }
4860
4861 #[test]
4862 fn final_input_preview_is_capped() {
4863 let many = serde_json::json!({"command": (1..=20).map(|i| format!("echo {i}")).collect::<Vec<_>>().join("\n")});
4864 let p = final_input_preview(&many);
4865 assert!(p.lines().count() <= MAX_REWRITE_PREVIEW_LINES + 1, "{p}");
4866 assert!(p.ends_with("…[preview truncated]"), "{p}");
4867 let wide = serde_json::json!({"path": "x".repeat(2000)});
4868 let p = final_input_preview(&wide);
4869 assert!(p.chars().count() <= MAX_REWRITE_PREVIEW_CHARS + 30, "{}", p.len());
4870 let obj = serde_json::json!({"path": "/tmp/a", "content": "hi"});
4871 assert!(final_input_preview(&obj).contains("\"path\": \"/tmp/a\""));
4872 }
4873
4874 #[test]
4877 fn long_confirm_message_is_capped() {
4878 let long = "x\n".repeat(500);
4879 let body = confirm_prompt_body(&long, &ext(&["jev"]), Some("bash"), None);
4880 assert!(body.starts_with("Extension: jev\nTool: bash\n"));
4881 assert!(body.contains("…[message truncated by host]"));
4882 assert!(body.lines().count() <= MAX_CONFIRM_MESSAGE_LINES + 8, "{}", body.lines().count());
4883 let wide = "y".repeat(10_000);
4884 assert!(confirm_prompt_body(&wide, &ext(&["jev"]), None, None).chars().count() < 1400);
4885 }
4886
4887 #[tokio::test]
4890 async fn unattributed_confirm_always_answer_does_not_latch() {
4891 let latch = crate::tools::SessionConfirmLatch::new();
4892 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4893 let handle = crate::tools::SecretPromptHandle::new(tx);
4894 let task = tokio::spawn(async move {
4895 let req = rx.recv().await.expect("prompt");
4896 assert!(req.confirm_scope.is_none());
4897 let _ = req.response_tx.send(Some(CONFIRM_ANSWER_ALLOW_ALL.to_string()));
4898 });
4899 let result = resolve_before_tool_call_result(
4900 crate::extensions::hooks::events::HookResult::confirm("Run deploy?"),
4901 Some(&handle),
4902 false,
4903 Some(&latch),
4904 )
4905 .await;
4906 task.await.unwrap();
4907 assert!(matches!(
4908 result,
4909 crate::extensions::hooks::events::HookResult::Continue
4910 ));
4911 assert!(latch.is_empty());
4912 }
4913
4914 #[tokio::test]
4918 async fn session_allow_all_does_not_override_block() {
4919 let latch = crate::tools::SessionConfirmLatch::new();
4920 latch.allow(&key(&["jev"]));
4921 let blocked = resolve_before_tool_call_result(
4922 crate::extensions::hooks::events::HookResult::Block {
4923 reason: "nope".into(),
4924 },
4925 None,
4926 false,
4927 Some(&latch),
4928 )
4929 .await;
4930 assert!(matches!(
4931 blocked,
4932 crate::extensions::hooks::events::HookResult::Block { reason } if reason == "nope"
4933 ));
4934 let headless = resolve_before_tool_call_result(
4935 sourced_confirm("Run deploy?", "jev", "bash"),
4936 None,
4937 false,
4938 Some(&latch),
4939 )
4940 .await;
4941 assert!(matches!(
4942 headless,
4943 crate::extensions::hooks::events::HookResult::Continue
4944 ));
4945 let other = resolve_before_tool_call_result(
4946 sourced_confirm("Run deploy?", "other", "bash"),
4947 None,
4948 false,
4949 Some(&latch),
4950 )
4951 .await;
4952 assert!(matches!(
4953 other,
4954 crate::extensions::hooks::events::HookResult::Block { .. }
4955 ));
4956 }
4957
4958 #[test]
4959 fn test_max_tokens_for_model() {
4960 assert_eq!(
4962 HelperMethods::max_tokens_for_model("claude-opus-4-6"),
4963 128000
4964 );
4965 assert_eq!(
4966 HelperMethods::max_tokens_for_model("opus-something"),
4967 128000
4968 );
4969
4970 assert_eq!(
4972 HelperMethods::max_tokens_for_model("claude-sonnet-4-20250514"),
4973 64000
4974 );
4975 assert_eq!(HelperMethods::max_tokens_for_model("haiku"), 64000);
4976 assert_eq!(HelperMethods::max_tokens_for_model("claude-3-haiku"), 64000);
4977 assert_eq!(
4978 HelperMethods::max_tokens_for_model("some-other-model"),
4979 64000
4980 );
4981
4982 assert_eq!(HelperMethods::max_tokens_for_model(""), 64000);
4984 assert_eq!(HelperMethods::max_tokens_for_model("OPUS"), 64000); assert_eq!(
4986 HelperMethods::max_tokens_for_model("model-opus-end"),
4987 128000
4988 ); }
4990
4991 #[test]
4992 fn test_truncate_tool_result() {
4993 let default_max = 30000;
4994
4995 let short = "This is a short string.";
4997 assert_eq!(
4998 HelperMethods::truncate_tool_result(short, default_max),
4999 short
5000 );
5001
5002 let exact = "x".repeat(30000);
5004 assert_eq!(
5005 HelperMethods::truncate_tool_result(&exact, default_max),
5006 exact
5007 );
5008
5009 let too_long = "x".repeat(30001);
5011 let truncated = HelperMethods::truncate_tool_result(&too_long, default_max);
5012
5013 assert!(truncated.starts_with(&"x".repeat(30000)));
5015
5016 assert!(truncated.contains("[truncated — 30001 total bytes, showing first 30000]"));
5018
5019 assert!(truncated.len() > 30000);
5021
5022 let very_long = "a".repeat(50000);
5024 let truncated_very_long = HelperMethods::truncate_tool_result(&very_long, default_max);
5025 assert!(
5026 truncated_very_long.contains("[truncated — 50000 total bytes, showing first 30000]")
5027 );
5028 assert!(truncated_very_long.starts_with(&"a".repeat(30000)));
5029
5030 let custom_truncated = HelperMethods::truncate_tool_result(&very_long, 100);
5032 assert!(custom_truncated.starts_with(&"a".repeat(100)));
5033 assert!(custom_truncated.contains("[truncated — 50000 total bytes, showing first 100]"));
5034 }
5035
5036 #[test]
5040 fn truncate_tool_result_enforces_byte_budget() {
5041 let multibyte = "é".repeat(100); let out = HelperMethods::truncate_tool_result(&multibyte, 50);
5043 let body = out.split("\n\n[truncated").next().unwrap();
5044 assert!(
5045 body.len() <= 50,
5046 "retained body must fit the byte budget (got {} bytes)",
5047 body.len()
5048 );
5049 assert!(std::str::from_utf8(body.as_bytes()).is_ok());
5050 assert!(
5051 out.contains("total bytes"),
5052 "marker must report byte counts, got: {out}"
5053 );
5054 }
5055
5056 #[test]
5057 fn test_thinking_level_ranges() {
5058 use crate::core::models::thinking_level_for_budget;
5059
5060 assert_eq!(thinking_level_for_budget(0), "adaptive");
5062
5063 assert_eq!(thinking_level_for_budget(1), "low");
5065 assert_eq!(thinking_level_for_budget(1024), "low");
5066 assert_eq!(thinking_level_for_budget(2048), "low");
5067
5068 assert_eq!(thinking_level_for_budget(2049), "medium");
5070 assert_eq!(thinking_level_for_budget(3000), "medium");
5071 assert_eq!(thinking_level_for_budget(4096), "medium");
5072
5073 assert_eq!(thinking_level_for_budget(4097), "high");
5075 assert_eq!(thinking_level_for_budget(8192), "high");
5076 assert_eq!(thinking_level_for_budget(16384), "high");
5077
5078 assert_eq!(thinking_level_for_budget(16385), "xhigh");
5080 assert_eq!(thinking_level_for_budget(32768), "xhigh");
5081 assert_eq!(thinking_level_for_budget(100000), "xhigh");
5082 }
5083}
5084
5085#[cfg(test)]
5086mod effective_prompt_tests {
5087 use super::*;
5088
5089 struct LateTool;
5092 #[async_trait::async_trait]
5093 impl crate::Tool for LateTool {
5094 fn name(&self) -> &str {
5095 "late_tool"
5096 }
5097 fn description(&self) -> &str {
5098 "Registered after the catalog froze."
5099 }
5100 fn parameters(&self) -> Value {
5101 json!({"type": "object", "properties": {}})
5102 }
5103 async fn execute(&self, _p: Value, _c: crate::ToolContext) -> crate::Result<String> {
5104 Ok(String::new())
5105 }
5106 }
5107
5108 #[tokio::test]
5109 async fn tool_catalog_lists_activatable_tools_outside_the_core() {
5110 let mut runtime = headless("anthropic/claude-sonnet-5-5", "BASE.");
5111 assert!(
5112 runtime.frozen_tool_catalog().await.is_none(),
5113 "flag off: no catalog"
5114 );
5115 runtime.progressive_tool_disclosure = true;
5116 let catalog = runtime.frozen_tool_catalog().await.expect("catalog");
5117 assert!(catalog.starts_with("## Activatable tools\n"));
5118 assert!(
5119 catalog.contains("not instructions"),
5120 "third-party text is framed as data"
5121 );
5122 assert!(catalog.contains("- builtin:subagent_start"), "{catalog}");
5123 for core in [
5124 "builtin:bash",
5125 "builtin:read",
5126 "builtin:activate_tools",
5127 "builtin:search_tools",
5128 ] {
5129 assert!(
5130 !catalog.contains(&format!("- {core}")),
5131 "core {core} listed"
5132 );
5133 }
5134 let prompt = runtime.effective_system_prompt().await.unwrap();
5135 assert!(prompt.starts_with("BASE."), "base stays first");
5136 assert!(prompt.ends_with(&catalog), "catalog appended last");
5137 }
5138
5139 #[tokio::test]
5143 async fn tools_always_loaded_flows_from_config_into_the_session_core() {
5144 let mut runtime = headless("anthropic/claude-sonnet-5-5", "BASE.");
5145 let config = agent_core::config::load_config_from_str(
5146 "progressive_tool_disclosure = true\ntools.always_loaded = builtin:subagent_start\n",
5147 );
5148 runtime.apply_config_keep_tools(&config);
5149 assert!(runtime.progressive_tool_disclosure);
5150 let listed = crate::tools::catalog::ToolId::builtin("subagent_start");
5151 assert_eq!(&*runtime.tools_always_loaded, std::slice::from_ref(&listed));
5152 assert_eq!(&*runtime.clone().tools_always_loaded, std::slice::from_ref(&listed));
5153 let catalog = runtime.frozen_tool_catalog().await.expect("catalog");
5154 assert!(!catalog.contains("- builtin:subagent_start"), "{catalog}");
5155 assert!(catalog.contains("- builtin:subagent_"), "other tools still listed");
5156 }
5157
5158 #[tokio::test]
5159 async fn tool_catalog_is_frozen_per_session() {
5160 let mut runtime = headless("anthropic/claude-sonnet-5-5", "BASE.");
5161 runtime.progressive_tool_disclosure = true;
5162 let first = runtime.effective_system_prompt().await.unwrap();
5163 runtime.tools.write().await.register(Arc::new(LateTool));
5164 let clone = runtime.clone();
5165 assert_eq!(runtime.effective_system_prompt().await.unwrap(), first);
5166 assert_eq!(clone.effective_system_prompt().await.unwrap(), first);
5167 assert!(
5168 !first.contains("late_tool"),
5169 "late registration does not rewrite the prefix"
5170 );
5171 }
5172
5173 #[tokio::test]
5174 async fn tool_catalog_absent_when_activation_is_denied_or_impossible() {
5175 let mut denied = headless("anthropic/claude-sonnet-5-5", "BASE.");
5176 denied.progressive_tool_disclosure = true;
5177 denied.set_activation_confirm(agent_core::config::ActivationConfirm::Deny);
5178 assert!(denied.frozen_tool_catalog().await.is_none());
5179 assert_eq!(
5180 denied.effective_system_prompt().await.as_deref(),
5181 Some("BASE.")
5182 );
5183
5184 let mut worker = headless("anthropic/claude-sonnet-5-5", "BASE.");
5185 worker.progressive_tool_disclosure = true;
5186 worker.set_tools(crate::ToolRegistry::without_subagent());
5187 assert!(
5188 worker.frozen_tool_catalog().await.is_none(),
5189 "no activate_tools, no catalog"
5190 );
5191 }
5192
5193 fn headless(model: &str, base: &str) -> Runtime {
5194 let mut runtime = Runtime::new_headless();
5195 runtime.set_model(model.to_string());
5196 runtime.set_system_prompt(base.to_string());
5197 runtime
5198 }
5199
5200 #[tokio::test]
5201 async fn anthropic_foreground_ultracode_composes_standing_doctrine_once() {
5202 let mut runtime = headless("anthropic/claude-fable-5", "BASE.");
5203 runtime.named_level = Some(agent_core::reasoning::ReasoningLevel::UltraCode);
5204 let prompt = runtime.effective_system_prompt().await.expect("doctrine");
5205 assert_eq!(prompt.matches("<anthropic-ultracode-workflow>").count(), 1);
5206 assert!(prompt.contains("subagent_start"));
5207 assert!(prompt.contains("subagent_resume"));
5208 }
5209
5210 #[tokio::test]
5211 async fn codex_foreground_with_subagent_tools_composes_doctrine() {
5212 let runtime = headless("openai-codex/gpt-5.6-sol", "BASE.");
5213 let prompt = runtime
5214 .effective_system_prompt()
5215 .await
5216 .expect("prompt must compose");
5217 assert!(prompt.starts_with("BASE."), "base must lead");
5218 assert!(prompt.contains("## Subagent supervision"));
5219 assert!(prompt.contains("NEVER end your turn"));
5220 }
5221
5222 #[tokio::test]
5223 async fn worker_runtime_without_subagent_tools_stays_clean() {
5224 let mut runtime = headless("openai-codex/gpt-5.6-sol", "WORKER.");
5225 runtime.set_tools(ToolRegistry::without_subagent());
5226 assert_eq!(
5227 runtime.effective_system_prompt().await.as_deref(),
5228 Some("WORKER."),
5229 "workers have no subagent tools; doctrine would be unactionable noise"
5230 );
5231 }
5232
5233 #[tokio::test]
5234 async fn non_codex_foreground_stays_clean() {
5235 let runtime = headless("xai-auth/grok-4.5-latest", "BASE.");
5236 assert_eq!(
5237 runtime.effective_system_prompt().await.as_deref(),
5238 Some("BASE.")
5239 );
5240 }
5241
5242 #[tokio::test]
5243 async fn manifest_prompt_is_returned_verbatim() {
5244 let mut runtime = headless("openai-codex/gpt-5.6-sol", "IGNORED.");
5245 let module = agent_core::prompt::PromptModule::new(
5246 agent_core::prompt::PromptModuleId::parse("kernel.test").unwrap(),
5247 "1.0.0",
5248 agent_core::prompt::PromptModuleSource::User,
5249 0,
5250 agent_core::prompt::PromptSelectors::default(),
5251 agent_core::prompt::ModuleMutability::MutableGuidance,
5252 "KERNEL.",
5253 )
5254 .unwrap();
5255 let context = agent_core::prompt::SelectionContext::new(
5256 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap(),
5257 None,
5258 )
5259 .unwrap();
5260 let stack = agent_core::prompt::PromptStack::new(vec![module], context).unwrap();
5261 runtime.apply_prompt_stack(stack).unwrap();
5262 assert_eq!(
5263 runtime.effective_system_prompt().await.as_deref(),
5264 Some("KERNEL."),
5265 "manifest authors own the full stack; no builtin injection on top"
5266 );
5267 }
5268
5269 #[tokio::test]
5270 async fn unresolvable_model_identity_fails_closed_to_base() {
5271 let runtime = headless("definitely-not-a-model", "BASE.");
5272 assert_eq!(
5273 runtime.effective_system_prompt().await.as_deref(),
5274 Some("BASE.")
5275 );
5276 }
5277}
5278
5279#[cfg(test)]
5280mod set_reasoning_level_checked_tests {
5281 use super::*;
5282 use agent_core::reasoning::ReasoningLevel;
5283
5284 fn codex_runtime(model_id: &str) -> Runtime {
5285 let mut rt = Runtime::new_headless();
5286 rt.set_model(format!("openai-codex/{model_id}"));
5287 rt.set_reasoning_level(ReasoningLevel::Low);
5288 rt
5289 }
5290
5291 fn provisioned_fable_runtime() -> Runtime {
5292 let mut rt = Runtime::new_headless();
5293 rt.set_model("anthropic/claude-fable-5".into());
5294 rt.set_reasoning_level(ReasoningLevel::Low);
5295 let foreground =
5296 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-fable-5").unwrap();
5297 let orchestration =
5298 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap();
5299 rt.install_orchestration(Arc::new(orchestration));
5300 rt
5301 }
5302
5303 fn install_typed_manifest(rt: &mut Runtime) {
5304 let module = agent_core::prompt::PromptModule::new(
5305 agent_core::prompt::PromptModuleId::parse("kernel.test").unwrap(),
5306 "1.0.0",
5307 agent_core::prompt::PromptModuleSource::User,
5308 0,
5309 agent_core::prompt::PromptSelectors::default(),
5310 agent_core::prompt::ModuleMutability::MutableGuidance,
5311 "KERNEL.",
5312 )
5313 .unwrap();
5314 let context = agent_core::prompt::SelectionContext::new(
5315 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-fable-5").unwrap(),
5316 None,
5317 )
5318 .unwrap();
5319 rt.apply_prompt_stack(agent_core::prompt::PromptStack::new(vec![module], context).unwrap())
5320 .unwrap();
5321 }
5322
5323 #[test]
5324 fn provisioned_fable_accepts_max_and_ultracode() {
5325 for level in [ReasoningLevel::Max, ReasoningLevel::UltraCode] {
5326 let mut rt = provisioned_fable_runtime();
5327 rt.set_reasoning_level_checked(level).unwrap();
5328 assert_eq!(rt.reasoning_level(), level);
5329 }
5330 }
5331
5332 #[test]
5333 fn fable_ultracode_prerequisites_fail_without_mutation() {
5334 let mut missing_orchestration = Runtime::new_headless();
5335 missing_orchestration.set_model("anthropic/claude-fable-5".into());
5336 missing_orchestration.set_reasoning_level(ReasoningLevel::Low);
5337 assert!(missing_orchestration
5338 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5339 .is_err());
5340 assert_eq!(missing_orchestration.reasoning_level(), ReasoningLevel::Low);
5341
5342 let mut missing_tools = provisioned_fable_runtime();
5343 missing_tools.set_tools(ToolRegistry::without_subagent());
5344 assert!(missing_tools
5345 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5346 .is_err());
5347 assert_eq!(missing_tools.reasoning_level(), ReasoningLevel::Low);
5348
5349 let mut worker = provisioned_fable_runtime();
5350 worker.set_codex_request_role(crate::runtime::openai::catalog::ExecutionRole::Worker);
5351 assert!(worker
5352 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5353 .is_err());
5354 assert_eq!(worker.reasoning_level(), ReasoningLevel::Low);
5355
5356 let mut internal = provisioned_fable_runtime();
5357 internal.set_codex_request_role(crate::runtime::openai::catalog::ExecutionRole::Internal);
5358 assert!(internal
5359 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5360 .is_err());
5361 assert_eq!(internal.reasoning_level(), ReasoningLevel::Low);
5362
5363 let mut manifest = provisioned_fable_runtime();
5364 install_typed_manifest(&mut manifest);
5365 assert!(manifest
5366 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5367 .is_err());
5368 assert_eq!(manifest.reasoning_level(), ReasoningLevel::Low);
5369 }
5370
5371 #[test]
5372 fn checked_accepts_client_omission_modes_for_codex() {
5373 for level in [ReasoningLevel::Off, ReasoningLevel::Adaptive] {
5374 let mut rt = codex_runtime("gpt-5.6-sol");
5375 rt.set_reasoning_level_checked(level).unwrap();
5376 assert_eq!(rt.reasoning_level(), level);
5377 }
5378 }
5379
5380 #[test]
5382 fn checked_rejects_ultra_for_luna_no_mutation() {
5383 let mut rt = codex_runtime("gpt-5.6-luna");
5384 let before = rt.reasoning_level();
5385 let err = rt
5386 .set_reasoning_level_checked(ReasoningLevel::Ultra)
5387 .unwrap_err();
5388 assert!(
5389 err.contains("ultra"),
5390 "error must name the rejected level; got: {err}"
5391 );
5392 assert_eq!(
5393 rt.reasoning_level(),
5394 before,
5395 "runtime must not be mutated when validation fails"
5396 );
5397 }
5398
5399 #[test]
5401 fn checked_rejects_max_for_gpt55_no_mutation() {
5402 let mut rt = codex_runtime("gpt-5.5");
5403 rt.set_reasoning_level(ReasoningLevel::Medium);
5404 let err = rt
5405 .set_reasoning_level_checked(ReasoningLevel::Max)
5406 .unwrap_err();
5407 assert!(
5408 err.contains("max"),
5409 "error must name the rejected level; got: {err}"
5410 );
5411 assert_eq!(rt.reasoning_level(), ReasoningLevel::Medium);
5412 }
5413
5414 #[test]
5416 fn checked_accepts_ultra_for_sol_and_mutates() {
5417 let mut rt = codex_runtime("gpt-5.6-sol");
5418 rt.set_reasoning_level_checked(ReasoningLevel::Ultra)
5419 .expect("sol must accept ultra");
5420 assert_eq!(rt.reasoning_level(), ReasoningLevel::Ultra);
5421 }
5422
5423 #[test]
5425 fn checked_rejects_extended_levels_without_metadata() {
5426 let mut rt = Runtime::new_headless();
5427 rt.set_model("claude-opus-4-7".to_string());
5428 rt.set_reasoning_level(ReasoningLevel::Low);
5429 for level in [ReasoningLevel::Max, ReasoningLevel::Ultra] {
5430 assert!(rt.set_reasoning_level_checked(level).is_err());
5431 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
5432 }
5433 }
5434
5435 #[test]
5438 fn checked_rejects_max_ultra_for_unknown_codex_model() {
5439 let mut rt = codex_runtime("gpt-unknown-future");
5440 rt.set_reasoning_level(ReasoningLevel::Low);
5441 for level in [ReasoningLevel::Max, ReasoningLevel::Ultra] {
5442 let err = rt.set_reasoning_level_checked(level).unwrap_err();
5443 assert!(err.contains("no capability metadata"), "{err}");
5444 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
5445 }
5446 }
5447
5448 #[test]
5451 fn checked_enforces_xai_capability_matrix_no_mutation_on_err() {
5452 let mut rt = Runtime::new_headless();
5453 rt.set_model("xai-auth/grok-4.5".to_string());
5454 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5456 for level in [
5457 ReasoningLevel::Off,
5458 ReasoningLevel::XHigh,
5459 ReasoningLevel::Ultra,
5460 ] {
5461 let before = rt.reasoning_level();
5462 assert!(rt.set_reasoning_level_checked(level).is_err(), "{level}");
5463 assert_eq!(rt.reasoning_level(), before);
5464 }
5465 rt.set_reasoning_level_checked(ReasoningLevel::Low)
5466 .expect("grok-4.5 supports low");
5467 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
5468 }
5469
5470 #[test]
5473 fn checked_rejects_named_on_intrinsic_xai_model_and_defaults_adaptive() {
5474 let mut rt = Runtime::new_headless();
5475 rt.set_model("xai-auth/grok-4.3".to_string());
5476 assert_eq!(rt.reasoning_level(), ReasoningLevel::Adaptive);
5477 assert!(rt
5478 .set_reasoning_level_checked(ReasoningLevel::Medium)
5479 .is_err());
5480 assert_eq!(rt.reasoning_level(), ReasoningLevel::Adaptive);
5481 rt.set_reasoning_level_checked(ReasoningLevel::Adaptive)
5482 .unwrap();
5483 }
5484
5485 #[test]
5487 fn explicit_level_survives_xai_model_switch() {
5488 let mut rt = Runtime::new_headless();
5489 rt.set_model("xai-auth/grok-4.5".to_string());
5490 rt.set_reasoning_level_checked(ReasoningLevel::Low).unwrap();
5491 rt.set_model("xai-auth/grok-4.5-latest".to_string());
5492 assert_eq!(
5493 rt.reasoning_level(),
5494 ReasoningLevel::Low,
5495 "explicit level must not be overwritten by the model default"
5496 );
5497 }
5498
5499 #[test]
5502 fn explicit_unsupported_level_is_clamped_to_model_default_on_switch() {
5503 let mut rt = Runtime::new_headless();
5504 rt.set_reasoning_level_explicit(ReasoningLevel::XHigh);
5505 let clamp = rt
5506 .try_set_model("xai-auth/grok-4.6".to_string())
5507 .expect("model switch succeeds");
5508 assert_eq!(
5509 clamp,
5510 Some(ReasoningClamp {
5511 from: ReasoningLevel::XHigh,
5512 to: ReasoningLevel::High,
5513 })
5514 );
5515 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5516 assert!(
5517 rt.is_reasoning_explicit(),
5518 "clamp keeps explicit provenance"
5519 );
5520 assert_eq!(rt.model(), "xai-auth/grok-4.6");
5521 }
5522
5523 #[test]
5525 fn explicit_supported_level_is_not_clamped_on_switch() {
5526 let mut rt = Runtime::new_headless();
5527 rt.set_reasoning_level_explicit(ReasoningLevel::High);
5528 let clamp = rt.try_set_model("xai-auth/grok-4.6".to_string()).unwrap();
5529 assert_eq!(clamp, None);
5530 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5531 assert!(rt.is_reasoning_explicit());
5532 }
5533
5534 #[test]
5537 fn non_explicit_default_application_reports_no_clamp() {
5538 let mut rt = Runtime::new_headless();
5539 rt.set_reasoning_level(ReasoningLevel::XHigh);
5540 let clamp = rt.try_set_model("xai-auth/grok-4.6".to_string()).unwrap();
5541 assert_eq!(clamp, None);
5542 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5543 assert!(!rt.is_reasoning_explicit());
5544 }
5545
5546 #[test]
5549 fn explicit_max_clamps_to_validating_option_on_multi_agent_model() {
5550 let model = "xai-auth/grok-4.20-multi-agent-0309";
5551 let mut rt = Runtime::new_headless();
5552 rt.set_reasoning_level_explicit(ReasoningLevel::Max);
5553 let clamp = rt
5554 .try_set_model(model.to_string())
5555 .unwrap()
5556 .expect("Max is not supported: must clamp");
5557 assert_eq!(clamp.from, ReasoningLevel::Max);
5558 assert!(
5559 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
5560 model, clamp.to
5561 )
5562 .is_ok(),
5563 "clamped level {} must validate",
5564 clamp.to
5565 );
5566 assert_eq!(rt.reasoning_level(), clamp.to);
5567 assert!(rt.is_reasoning_explicit());
5568 }
5569
5570 #[test]
5572 fn set_model_returns_clamp() {
5573 let mut rt = Runtime::new_headless();
5574 rt.set_reasoning_level_explicit(ReasoningLevel::XHigh);
5575 let clamp = rt.set_model("xai-auth/grok-4.6".to_string());
5576 assert_eq!(clamp.map(|c| c.to), Some(ReasoningLevel::High));
5577 }
5578
5579 #[test]
5582 fn restore_session_reasoning_clamps_after_reapply() {
5583 let mut rt = Runtime::new_headless();
5584 rt.set_model("xai-auth/grok-4.6".to_string());
5585 let clamp = rt.restore_session_reasoning("xhigh");
5586 assert_eq!(
5587 clamp,
5588 Some(ReasoningClamp {
5589 from: ReasoningLevel::XHigh,
5590 to: ReasoningLevel::High,
5591 })
5592 );
5593 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5594 assert!(rt.is_reasoning_explicit());
5595 assert!(rt.set_reasoning_level_checked(rt.reasoning_level()).is_ok());
5596 }
5597
5598 #[test]
5600 fn restore_session_reasoning_keeps_supported_level() {
5601 let mut rt = Runtime::new_headless();
5602 rt.set_model("xai-auth/grok-4.6".to_string());
5603 assert_eq!(rt.restore_session_reasoning("low"), None);
5604 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
5605 assert!(rt.is_reasoning_explicit());
5606 }
5607
5608 #[test]
5611 fn apply_config_clamps_unsupported_explicit_level_against_config_model() {
5612 let mut rt = Runtime::new_headless();
5613 rt.apply_config(&crate::config::SynapsConfig {
5614 model: Some("xai-auth/grok-4.6".to_string()),
5615 thinking_level: Some(ReasoningLevel::XHigh),
5616 ..Default::default()
5617 });
5618 assert_eq!(rt.model(), "xai-auth/grok-4.6");
5619 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5620 assert!(rt.is_reasoning_explicit());
5621 assert!(rt.set_reasoning_level_checked(rt.reasoning_level()).is_ok());
5622 }
5623}
5624
5625#[cfg(test)]
5626mod codex_execution_preflight_tests {
5627 use super::*;
5628 use crate::runtime::openai::catalog::CodexRequestRole;
5629 use agent_core::prompt::QualifiedModelId;
5630 use agent_core::reasoning::ReasoningLevel;
5631
5632 fn ultra_runtime() -> Runtime {
5633 let mut runtime = Runtime::new_headless();
5634 runtime.set_model("openai-codex/gpt-5.6-sol".to_string());
5635 runtime.set_reasoning_level_explicit(ReasoningLevel::Ultra);
5636 runtime
5637 }
5638
5639 fn install_baseline(runtime: &mut Runtime) {
5640 let foreground =
5641 QualifiedModelId::parse("openai-codex/gpt-5.6-sol").expect("qualified model");
5642 let orchestration = crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8)
5643 .expect("baseline orchestration");
5644 runtime.install_orchestration(Arc::new(orchestration));
5645 }
5646
5647 #[tokio::test]
5648 async fn foreground_ultra_preflight_requires_orchestration() {
5649 let runtime = ultra_runtime();
5650 let error = runtime
5651 .validate_request_preflight()
5652 .await
5653 .expect_err("Ultra without orchestration must fail closed");
5654 assert!(error.to_string().contains("orchestration"), "{error}");
5655 }
5656
5657 #[tokio::test]
5658 async fn foreground_ultra_preflight_requires_actionable_subagent_tools() {
5659 let mut runtime = ultra_runtime();
5660 install_baseline(&mut runtime);
5661 runtime.set_tools(ToolRegistry::without_subagent());
5662
5663 let error = runtime
5664 .validate_request_preflight()
5665 .await
5666 .expect_err("Ultra without subagent tools must fail closed");
5667 assert!(error.to_string().contains("subagent"), "{error}");
5668 }
5669
5670 #[tokio::test]
5671 async fn foreground_ultra_preflight_accepts_exact_model_policy_and_tools() {
5672 let mut runtime = ultra_runtime();
5673 install_baseline(&mut runtime);
5674 runtime
5675 .validate_request_preflight()
5676 .await
5677 .expect("fully provisioned Ultra must pass preflight");
5678 }
5679
5680 #[tokio::test]
5681 async fn worker_ultra_preflight_never_requires_or_enables_orchestration() {
5682 let mut runtime = ultra_runtime();
5683 runtime.set_codex_request_role(CodexRequestRole::Worker);
5684 runtime.set_tools(ToolRegistry::without_subagent());
5685 runtime
5686 .validate_request_preflight()
5687 .await
5688 .expect("worker Ultra is underlying max without proactive orchestration");
5689 assert_eq!(runtime.codex_request_role(), CodexRequestRole::Worker);
5690 }
5691
5692 #[tokio::test]
5693 async fn non_codex_ultra_preflight_fails_before_provider_work() {
5694 let mut runtime = Runtime::new_headless();
5695 runtime.set_model("anthropic/claude-opus-4-7".to_string());
5696 runtime.set_reasoning_level_explicit(ReasoningLevel::Ultra);
5697 let error = runtime
5698 .validate_request_preflight()
5699 .await
5700 .expect_err("non-Codex Ultra must fail closed");
5701 assert!(
5702 error.to_string().contains("unsupported_reasoning_level"),
5703 "{error}"
5704 );
5705 }
5706
5707 #[tokio::test]
5708 async fn compaction_preflight_validates_target_before_credentials() {
5709 let mut runtime = ultra_runtime();
5710 runtime.set_compaction_model(Some("claude-sonnet-4-6".to_string()));
5711 {
5712 let mut auth = runtime.auth.write().await;
5713 auth.auth_token.clear();
5714 auth.auth_type = "none".to_string();
5715 auth.refresh_token = None;
5716 auth.token_expires = None;
5717 }
5718
5719 let error = runtime
5720 .compact_call(Vec::new())
5721 .await
5722 .expect_err("Codex Ultra cannot silently cross into Anthropic compaction");
5723 assert!(
5724 error.to_string().contains("authoritative exact-model"),
5725 "preflight must reject the target before auth access: {error}"
5726 );
5727 }
5728}
5729
5730#[cfg(test)]
5731mod http_client_timeout_tests {
5732 use futures::StreamExt;
5746 use std::time::Duration;
5747 use tokio::io::AsyncWriteExt;
5748
5749 const CHUNKED_HEAD: &[u8] =
5750 b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ntransfer-encoding: chunked\r\n\r\n";
5751
5752 fn chunk(data: &str) -> Vec<u8> {
5753 format!("{:x}\r\n{}\r\n", data.len(), data).into_bytes()
5754 }
5755
5756 async fn spawn_chunked_server(n_chunks: usize, gap: Duration, terminate: bool) -> String {
5759 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5760 let addr = listener.local_addr().unwrap();
5761 tokio::spawn(async move {
5762 let (mut sock, _) = listener.accept().await.unwrap();
5763 let mut buf = [0u8; 4096];
5765 use tokio::io::AsyncReadExt;
5766 let _ = sock.read(&mut buf).await;
5767 sock.write_all(CHUNKED_HEAD).await.unwrap();
5768 for i in 0..n_chunks {
5769 sock.write_all(&chunk(&format!("data {i}\n")))
5770 .await
5771 .unwrap();
5772 sock.flush().await.unwrap();
5773 tokio::time::sleep(gap).await;
5774 }
5775 if terminate {
5776 sock.write_all(b"0\r\n\r\n").await.unwrap();
5777 } else {
5778 tokio::time::sleep(Duration::from_secs(60)).await;
5780 }
5781 });
5782 format!("http://{addr}/stream")
5783 }
5784
5785 async fn drain(
5786 client: &reqwest::Client,
5787 url: &str,
5788 ) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
5789 let resp = client.get(url).send().await?;
5790 let mut stream = resp.bytes_stream();
5791 let mut total = 0usize;
5792 while let Some(c) = stream.next().await {
5793 total += c?.len();
5794 }
5795 Ok(total)
5796 }
5797
5798 #[tokio::test]
5803 async fn active_stream_outliving_read_timeout_survives() {
5804 let url = spawn_chunked_server(15, Duration::from_millis(100), true).await;
5806 let client = super::build_http_client(Duration::from_millis(400)).expect("client builds");
5807 let total = drain(&client, &url)
5808 .await
5809 .expect("active stream must never be killed by the idle timeout");
5810 assert!(total > 0);
5811 }
5812
5813 #[tokio::test]
5816 async fn stalled_stream_is_killed_by_read_timeout() {
5817 let url = spawn_chunked_server(2, Duration::from_millis(10), false).await;
5819 let client = super::build_http_client(Duration::from_millis(300)).expect("client builds");
5820 let start = std::time::Instant::now();
5821 let err = drain(&client, &url)
5822 .await
5823 .expect_err("stalled stream must be detected");
5824 assert!(
5825 start.elapsed() < Duration::from_secs(5),
5826 "stall must be detected promptly, took {:?}",
5827 start.elapsed()
5828 );
5829 let msg = crate::core::error::error_chain_string(err.as_ref());
5830 assert!(
5831 msg.contains("timed out") || msg.contains("timeout"),
5832 "must be a timeout error: {msg}"
5833 );
5834 }
5835
5836 #[tokio::test]
5840 async fn hung_headers_are_killed_by_read_timeout() {
5841 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5842 let addr = listener.local_addr().unwrap();
5843 tokio::spawn(async move {
5844 let (_sock, _) = listener.accept().await.unwrap();
5845 tokio::time::sleep(Duration::from_secs(60)).await;
5846 });
5847 let client = super::build_http_client(Duration::from_millis(300)).expect("client builds");
5848 let start = std::time::Instant::now();
5849 let err = client
5850 .get(format!("http://{addr}/codex/responses"))
5851 .send()
5852 .await
5853 .expect_err("hung request must be detected");
5854 assert!(
5855 start.elapsed() < Duration::from_secs(5),
5856 "hung headers must be detected promptly, took {:?}",
5857 start.elapsed()
5858 );
5859 assert!(err.is_timeout(), "must be a timeout: {err:?}");
5860 }
5861}
5862
5863#[cfg(test)]
5866mod memory_binding_tests {
5867 use super::Runtime;
5868 use crate::test_env::{BaseDirGuard, EnvVarGuard};
5869 use crate::tools::test_helpers::context_with_memory;
5870 use crate::tools::Tool;
5871 use serde_json::json;
5872 use std::path::Path;
5873
5874 fn project() -> tempfile::TempDir {
5876 let dir = tempfile::tempdir().unwrap();
5877 std::fs::write(dir.path().join(".synaps-project"), "").unwrap();
5878 dir
5879 }
5880
5881 fn key_of(dir: &Path) -> String {
5882 let scope = agent_core::memory::store::ProjectScope::discover(dir).unwrap();
5883 scope.key().to_owned()
5884 }
5885
5886 fn bound_key(runtime: &Runtime) -> String {
5887 runtime.memory_backend.scope().unwrap().key().to_owned()
5888 }
5889
5890 const OLD_AXEL_CONFIG: &str = "memory.backend = axel\n\
5894 memory.user_scope = true\n\
5895 memory.axel.executable = /nonexistent/synaps-axel-memory-service\n\
5896 memory.axel.brain = relative/brain.r8\n\
5897 context_management.mode = auto\n";
5898
5899 #[test]
5904 #[serial_test::serial(synaps_base_dir)]
5905 fn a_runtime_binds_its_folders_project_once() {
5906 let _root = EnvVarGuard::remove("SYNAPS_PROJECT_ROOT");
5907 let (first, second) = (project(), project());
5908 assert_ne!(key_of(first.path()), key_of(second.path()));
5909 let plain = agent_core::config::load_config_from_str("");
5910 let old = agent_core::config::load_config_from_str(OLD_AXEL_CONFIG);
5911
5912 let mut runtime = Runtime::new_headless();
5913 runtime.set_cwd(Some(first.path().to_path_buf()));
5914 runtime.apply_config(&plain);
5915 assert_eq!(bound_key(&runtime), key_of(first.path()));
5916 let author = runtime.memory_backend.author().clone();
5917
5918 runtime.set_cwd(Some(second.path().to_path_buf()));
5919 runtime.apply_config(&plain);
5920 runtime.apply_config(&old);
5921 runtime.bind_memory();
5922 assert_eq!(bound_key(&runtime), key_of(first.path()));
5923 assert_eq!(runtime.memory_backend.author(), &author);
5924
5925 let clone = runtime.clone();
5926 assert_eq!(bound_key(&clone), key_of(first.path()));
5927 assert_eq!(clone.memory_backend.author(), &author);
5928 assert_eq!(clone.memory_backend.base(), runtime.memory_backend.base());
5929 }
5930
5931 #[tokio::test]
5938 #[serial_test::serial(synaps_base_dir)]
5939 async fn an_old_axel_config_starts_a_runtime_on_plain_files() {
5940 let base = BaseDirGuard::new();
5941 let _root = EnvVarGuard::remove("SYNAPS_PROJECT_ROOT");
5942 let chat = project();
5943 let config = agent_core::config::load_config_from_str(OLD_AXEL_CONFIG);
5944 assert_eq!(config.warnings.len(), 1, "{:?}", config.warnings);
5945 assert!(config.warnings[0].starts_with("The Axel memory backend (memory.backend = axel, "));
5946 assert_eq!(
5947 config.context_management.mode,
5948 agent_core::config::ContextManagementMode::Auto
5949 );
5950
5951 let mut runtime = Runtime::new_headless();
5952 runtime.set_cwd(Some(chat.path().to_path_buf()));
5953 runtime.apply_config(&config);
5954 runtime.apply_config(&config);
5955 runtime
5958 .validate_request_preflight()
5959 .await
5960 .expect("an old Axel config passes the request preflight");
5961 let key = key_of(chat.path());
5962 assert_eq!(bound_key(&runtime), key);
5963 assert_eq!(runtime.memory_backend.base(), base.path());
5964 assert!(runtime.context_management_enabled());
5967 runtime.context_management_command("auto").expect("/context auto is not refused");
5968
5969 let ctx = || context_with_memory(runtime.memory_backend.clone());
5970 let stored = crate::tools::memory::MemoryStoreTool
5971 .execute(json!({"content": "NOTE_UNDER_AN_OLD_CONFIG"}), ctx())
5972 .await
5973 .unwrap();
5974 assert!(stored.contains(&format!("project {key}")), "{stored}");
5975 let id = stored.split_whitespace().nth(2).unwrap().to_owned();
5976 let file = base.path().join("memory").join(format!("project-{key}.jsonl"));
5978 let lines = std::fs::read_to_string(&file).expect("the project's notes file");
5979 assert!(lines.contains("NOTE_UNDER_AN_OLD_CONFIG") && lines.contains(&id), "{lines}");
5980 let found = crate::tools::memory::MemorySearchTool
5981 .execute(json!({"query": "NOTE_UNDER_AN_OLD_CONFIG"}), ctx())
5982 .await
5983 .unwrap();
5984 assert!(found.contains(&id), "{found}");
5985 let fetched = crate::tools::memory::MemoryFetchTool
5986 .execute(json!({"ids": [id]}), ctx())
5987 .await
5988 .unwrap();
5989 assert!(fetched.contains("NOTE_UNDER_AN_OLD_CONFIG"), "{fetched}");
5990
5991 let window = vec![std::sync::Arc::new(json!({"role": "user", "content": "sealed words"}))];
5993 let (parts, _) = runtime
5994 .memory_backend
5995 .history_seal_window("old-config-chat", &window, "")
5996 .await
5997 .unwrap();
5998 let archive = &parts[0].archive.id;
5999 let sealed = agent_core::context_archive::archive_file(base.path(), &key, archive);
6000 assert!(sealed.unwrap().expect("an archive file").starts_with(base.path()));
6001 let history = crate::tools::memory::MemoryFetchTool
6002 .execute(json!({"ids": [format!("ctx-{archive}")]}), ctx())
6003 .await
6004 .unwrap();
6005 assert!(history.contains("sealed words"), "{history}");
6006
6007 crate::tools::memory::MemoryForgetTool
6008 .execute(json!({"id": id}), ctx())
6009 .await
6010 .unwrap();
6011 assert!(base.path().join("memory").is_dir());
6014 assert!(base.path().join("context-archives").is_dir());
6015 assert!(!base.path().join("memory/axel").exists());
6016 assert!(!chat.path().join("relative").exists());
6017 }
6018
6019 #[test]
6023 #[serial_test::serial(synaps_base_dir)]
6024 fn a_binding_without_a_folder_has_no_project() {
6025 let gone = tempfile::tempdir().unwrap();
6026 let gone_path = gone.path().join("deleted");
6027 std::fs::create_dir(&gone_path).unwrap();
6028 std::fs::remove_dir(&gone_path).unwrap();
6029 for cwd in [None, Some(gone_path)] {
6030 let mut runtime = Runtime::new_headless();
6031 runtime.memory_backend = crate::memory_backend::MemoryBinding::for_cwd(cwd.clone());
6032 assert!(runtime.memory_backend.scope().is_err(), "{cwd:?} must have no project");
6033 }
6034 }
6035
6036 #[test]
6039 #[serial_test::serial(synaps_base_dir)]
6040 fn an_old_automatic_memory_config_changes_nothing_on_a_runtime() {
6041 let old = agent_core::config::load_config_from_str(
6042 "memory.default_mode = capture_and_recall\n\
6043 memory.default_mode_confirmed = true\n\
6044 memory.recall_max_records = 12\n\
6045 memory.recall_max_tokens = 2048\n\
6046 memory.recall_timeout_ms = 250\n\
6047 memory.capture_tools = off\n\
6048 memory.capture_assistant = false\n\
6049 memory.capture_user = false\n\
6050 memory.auto_consolidate = on\n\
6051 memory.local_embeddings = on\n\
6052 memory.gliner = on\n",
6053 );
6054 assert_eq!(old.warnings.len(), 1, "{:?}", old.warnings);
6055 let plain = agent_core::config::load_config_from_str("");
6056 let mut with_old = Runtime::new_headless();
6057 with_old.apply_config(&old);
6058 with_old.apply_config(&old);
6059 let mut with_plain = Runtime::new_headless();
6060 with_plain.apply_config(&plain);
6061 assert_eq!(with_old.memory_backend.base(), with_plain.memory_backend.base());
6062 assert_eq!(
6063 with_old.memory_backend.scope().ok().map(|scope| scope.key().to_owned()),
6064 with_plain.memory_backend.scope().ok().map(|scope| scope.key().to_owned()),
6065 );
6066 }
6067
6068 #[test]
6071 #[serial_test::serial(synaps_base_dir)]
6072 fn any_backend_selector_binds_the_same_plain_files() {
6073 let _root = EnvVarGuard::remove("SYNAPS_PROJECT_ROOT");
6074 let chat = project();
6075 for selector in ["axel", "legacy", "sqlite", "invalid-not-axel-not-legacy", ""] {
6076 let mut runtime = Runtime::new_headless();
6077 runtime.set_cwd(Some(chat.path().to_path_buf()));
6078 runtime.apply_config(&agent_core::config::load_config_from_str(&format!(
6079 "memory.backend = {selector}\n"
6080 )));
6081 assert_eq!(bound_key(&runtime), key_of(chat.path()), "{selector}");
6082 assert_eq!(runtime.memory_backend.base(), agent_core::config::base_dir());
6083 }
6084 }
6085}