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 api;
14mod api_sync;
15pub mod attachments;
16mod auth;
17mod axel_context;
18#[cfg(test)]
19mod body_golden;
20pub mod budget;
21pub mod capture_worker;
22pub mod chat_capture;
23pub(crate) mod cloud_invoke;
24pub mod compaction;
25pub mod context;
26pub mod continuation;
27pub mod google_gemini;
28pub mod google_vertex;
29pub(crate) mod helpers;
30pub mod memory_context;
31pub mod memory_history;
32pub mod openai;
33pub mod relay;
34mod request;
35mod sse;
36mod sse_types;
37mod stream;
38pub mod subagent;
39pub mod telemetry;
40pub mod trace;
41pub(crate) mod transport;
42mod types;
43
44use api::ApiMethods;
45use auth::AuthMethods;
46use helpers::HelperMethods;
47use stream::StreamMethods;
48use types::AuthState;
49pub use types::{AgentEvent, CancelCause, LlmEvent, SessionEvent, StreamEvent, TurnCompletion};
50pub use stream::activation_policy;
51
52pub enum BeforeToolCallDecision {
54 Continue { input: Value },
55 Block { reason: String },
56}
57
58pub async fn emit_before_tool_call(
62 hook_bus: &Arc<crate::extensions::hooks::HookBus>,
63 tool_name: &str,
64 runtime_tool_name: Option<&str>,
65 input: Value,
66 session_id: Option<&str>,
67) -> crate::extensions::hooks::events::HookResult {
68 let mut event = crate::extensions::hooks::events::HookEvent::before_tool_call(tool_name, input)
69 .with_session(session_id);
70 if let Some(runtime_tool_name) = runtime_tool_name {
71 event.tool_runtime_name = Some(runtime_tool_name.to_string());
72 }
73 hook_bus.emit(&event).await
74}
75
76pub async fn resolve_before_tool_call_result(
81 hook_result: crate::extensions::hooks::events::HookResult,
82 secret_prompt: Option<&crate::tools::SecretPromptHandle>,
83 auto_approve_confirms: bool,
84) -> crate::extensions::hooks::events::HookResult {
85 match hook_result {
86 crate::extensions::hooks::events::HookResult::Confirm { message } => {
87 if auto_approve_confirms {
88 tracing::info!(message = %message, "confirm auto-approved (auto_approve_confirms=true)");
89 return crate::extensions::hooks::events::HookResult::Continue;
90 }
91
92 let Some(prompt) = secret_prompt else {
93 return crate::extensions::hooks::events::HookResult::Block {
94 reason: format!(
95 "Tool call requires confirmation but no interactive prompt is available: {}",
96 message
97 ),
98 };
99 };
100
101 let response = prompt
102 .prompt(
103 "Confirm tool call".to_string(),
104 format!("{}\n\nType 'yes' or 'y' to allow.", message),
105 )
106 .await;
107
108 match response.as_deref().map(str::trim) {
109 Some(answer)
110 if answer.eq_ignore_ascii_case("yes") || answer.eq_ignore_ascii_case("y") =>
111 {
112 crate::extensions::hooks::events::HookResult::Continue
113 }
114 _ => crate::extensions::hooks::events::HookResult::Block {
115 reason: format!("Tool call confirmation denied: {}", message),
116 },
117 }
118 }
119 other => other,
120 }
121}
122
123pub async fn resolve_before_tool_call_decision(
125 original_input: Value,
126 hook_result: crate::extensions::hooks::events::HookResult,
127 secret_prompt: Option<&crate::tools::SecretPromptHandle>,
128 auto_approve_confirms: bool,
129) -> BeforeToolCallDecision {
130 match resolve_before_tool_call_result(hook_result, secret_prompt, auto_approve_confirms).await {
131 crate::extensions::hooks::events::HookResult::Block { reason } => {
132 BeforeToolCallDecision::Block { reason }
133 }
134 crate::extensions::hooks::events::HookResult::Modify { input } => {
135 BeforeToolCallDecision::Continue { input }
136 }
137 _ => BeforeToolCallDecision::Continue {
138 input: original_input,
139 },
140 }
141}
142
143const MAX_REPLACE_OUTPUT: usize = 1024 * 1024; pub async fn emit_after_tool_call(
168 hook_bus: &Arc<crate::extensions::hooks::HookBus>,
169 tool_name: &str,
170 runtime_tool_name: Option<&str>,
171 input: Value,
172 output: String,
173 max_tool_output: usize,
174 session_id: Option<&str>,
175) -> String {
176 use crate::extensions::hooks::events::HookResult;
177 let original = output.clone();
179 let mut event =
180 crate::extensions::hooks::events::HookEvent::after_tool_call(tool_name, input, output)
181 .with_session(session_id);
182 if let Some(runtime_tool_name) = runtime_tool_name {
183 event.tool_runtime_name = Some(runtime_tool_name.to_string());
184 }
185 let post_hook = match hook_bus.emit(&event).await {
186 HookResult::Replace { mut output } => {
187 if output.len() > MAX_REPLACE_OUTPUT {
188 tracing::warn!(
189 tool = %tool_name,
190 len = output.len(),
191 cap = MAX_REPLACE_OUTPUT,
192 "Extension Replace output exceeds cap — clamping",
193 );
194 let mut boundary = MAX_REPLACE_OUTPUT;
196 while boundary > 0 && !output.is_char_boundary(boundary) {
197 boundary -= 1;
198 }
199 output.truncate(boundary);
200 }
201 output
202 }
203 HookResult::Continue => original,
204 other => {
205 tracing::warn!(
206 tool = %tool_name,
207 ?other,
208 "Unexpected after_tool_call result — using original output",
209 );
210 original
211 }
212 };
213 crate::runtime::helpers::HelperMethods::truncate_tool_result(&post_hook, max_tool_output)
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub struct ReasoningClamp {
223 pub from: agent_core::reasoning::ReasoningLevel,
224 pub to: agent_core::reasoning::ReasoningLevel,
225}
226
227fn validated_single_tool_output(
228 model: &str,
229 output: crate::ToolOutput,
230) -> (String, Option<Vec<Value>>) {
231 let (summary, blocks) = output.into_parts();
232 if let Some(ref blocks) = blocks {
233 if let Err(error) = attachments::validate_tool_blocks(model, blocks) {
234 return (format!("Attachment not sent: {error}"), None);
235 }
236 }
237 (summary, blocks)
238}
239
240fn retain_single_tool_blocks(
243 blocks: Option<Vec<Value>>,
244 original: &str,
245 hooked: &str,
246) -> Option<Vec<Value>> {
247 if original == hooked {
248 blocks
249 } else {
250 None
251 }
252}
253
254fn single_tool_result_content(
255 result: &str,
256 blocks: Option<Vec<Value>>,
257 max_tool_output: usize,
258) -> Value {
259 match blocks {
260 Some(blocks) => Value::Array(blocks),
261 None => Value::String(HelperMethods::truncate_tool_result(result, max_tool_output)),
262 }
263}
264
265#[derive(Clone)]
268struct PromptReloadSource {
269 manifest: PathBuf,
270 context: agent_core::prompt::SelectionContext,
271 user_module: Option<agent_core::prompt::PromptModule>,
272 delegation_policy_digest: Option<String>,
273}
274
275pub struct Runtime {
276 client: Client,
277 auth: Arc<RwLock<AuthState>>,
278 model: String,
279 tools: Arc<RwLock<ToolRegistry>>,
280 system_prompt: Option<String>,
281 effective_prompt: Option<agent_core::prompt::PromptStack>,
283 prompt_generation: u64,
284 prompt_reload_source: Option<PromptReloadSource>,
286 thinking_budget: u32,
287 named_level: Option<agent_core::reasoning::ReasoningLevel>,
291 explicit_reasoning: bool,
296 codex_request_role: crate::runtime::openai::catalog::CodexRequestRole,
299 context_window_override: Option<u64>,
304 pub(crate) continuation: continuation::SharedContinuation,
307 compaction_model: Option<String>,
309 compaction_mode: agent_core::compaction::CompactionMode,
311 compaction_exclusions: Vec<agent_core::compaction::ContentClass>,
313 remote_summarization_attempts: Arc<std::sync::atomic::AtomicU64>,
318 subagent_registry: Arc<Mutex<crate::runtime::subagent::SubagentRegistry>>,
320 orchestration: Option<Arc<crate::orchestration::OrchestrationRuntime>>,
322 event_queue: Arc<crate::events::EventQueue>,
324 pub watcher_exit_path: Option<PathBuf>,
326 max_tool_output: usize,
328 bash_timeout: u64,
329 bash_max_timeout: u64,
330 subagent_timeout: u64,
331 api_retries: u32,
332 refusal_retries: u32,
333 telemetry_level: crate::runtime::telemetry::TelemetryLevel,
335 telemetry_writer: Option<crate::runtime::telemetry::TelemetryWriter>,
341 trace_ctx: trace::TraceContext,
346 trace_controls: std::sync::Arc<trace::TraceControls>,
350 memory_context_state: std::sync::Arc<std::sync::Mutex<memory_context::SessionMemoryState>>,
362 memory_backend: crate::memory_backend::MemoryBinding,
365 memory_backend_config: Option<crate::config::MemoryBackendConfig>,
367 memory_backend_reconfigure_denied: Arc<std::sync::atomic::AtomicBool>,
369 #[cfg(any(test, feature = "testing"))]
373 capture_provider_for_test: std::sync::Arc<
374 std::sync::Mutex<Option<std::sync::Arc<dyn capture_worker::CaptureProvider>>>,
375 >,
376 pending_history_import_preview:
380 std::sync::Arc<std::sync::Mutex<Option<memory_history::HistoryImportPreview>>>,
381 history_import_plan: std::sync::Arc<std::sync::Mutex<Option<memory_history::ImportPlan>>>,
383 pending_memory_segment:
394 std::sync::Arc<std::sync::Mutex<Option<memory_context::ContextSegment>>>,
395 retained_recall_turn:
402 std::sync::Arc<std::sync::Mutex<Option<memory_context::RetainedRecallTurn>>>,
403 one_shot_trace_writer:
411 std::sync::Arc<std::sync::Mutex<Option<crate::runtime::telemetry::TelemetryWriter>>>,
412 capture_dir: std::path::PathBuf,
418 cache_diagnostics: bool,
420 cache_ttl: crate::core::config::CacheTtl,
423 ttl_downgrade_notified: std::sync::Arc<std::sync::atomic::AtomicBool>,
426 saw_1h_honored: std::sync::Arc<std::sync::atomic::AtomicBool>,
430 #[allow(dead_code)]
434 last_msg_id: Arc<Mutex<Option<String>>>,
435 session_manager: std::sync::Arc<crate::tools::shell::SessionManager>,
436 hook_bus: Arc<crate::extensions::hooks::HookBus>,
438 #[allow(dead_code)]
440 reaper_handle: Option<tokio::task::JoinHandle<()>>,
441 #[allow(dead_code)]
442 reaper_cancel: Option<tokio_util::sync::CancellationToken>,
443 credential_source: crate::auth::CredentialSource,
447 token_cache: crate::auth::TokenCache,
450 trusted_worker_models: Vec<agent_core::prompt::QualifiedModelId>,
454 progressive_tool_disclosure: bool,
458 activation_confirm: agent_core::config::ActivationConfirm,
461 delegation_parent: Option<String>,
464 mcp_runtime: Option<std::sync::Arc<crate::mcp::McpRuntimeManager>>,
468 mcp_session_scope: Option<std::sync::Arc<crate::mcp::McpSessionEndGuard>>,
472 extension_runtime: Option<std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>>,
476 extension_session_scope:
480 Option<std::sync::Arc<crate::extensions::lease::ExtensionSessionEndGuard>>,
481 turn_budget: crate::runtime::budget::TurnBudget,
484 host_tool_session: crate::tools::activation::SessionId,
492 retained_tool_set: crate::tools::activation::RetainedSessionToolSet,
496 session_id: Option<String>,
500 cwd: Option<PathBuf>,
504 env: Option<crate::session::types::SessionEnv>,
508 env_stripped: Vec<String>,
510 env_warned: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
514}
515
516fn fresh_host_tool_session() -> crate::tools::activation::SessionId {
520 crate::tools::activation::SessionId::parse(&format!(
521 "runtime-{}-{}",
522 std::process::id(),
523 uuid::Uuid::new_v4()
524 ))
525 .expect("generated runtime session id is always valid")
526}
527
528fn fresh_memory_context_state(
535) -> std::sync::Arc<std::sync::Mutex<memory_context::SessionMemoryState>> {
536 let session = memory_context::SessionId::parse(&format!(
537 "memctx-{}-{}",
538 std::process::id(),
539 uuid::Uuid::new_v4()
540 ))
541 .expect("generated memory session id is always valid");
542 std::sync::Arc::new(std::sync::Mutex::new(
543 memory_context::SessionMemoryState::new(session),
544 ))
545}
546
547fn memory_project_scope() -> std::result::Result<agent_core::memory::store::ProjectScope, String> {
551 let cwd = std::env::current_dir()
552 .map_err(|error| format!("cannot resolve current project directory: {error}"))?;
553 agent_core::memory::store::ProjectScope::discover(&cwd)
554 .map_err(|error| format!("cannot resolve project scope: {error}"))
555}
556
557fn memory_project_id() -> memory_context::ProjectId {
558 match memory_project_scope() {
559 Ok(scope) => memory_context::ProjectId::parse(scope.key())
560 .expect("host project scope key is always a valid identifier"),
561 Err(_) => memory_context::ProjectId::parse("project-unresolved")
562 .expect("static fallback project id is valid"),
563 }
564}
565
566fn memory_capture_worker() -> &'static capture_worker::CaptureWorker {
575 static WORKER: std::sync::OnceLock<capture_worker::CaptureWorker> = std::sync::OnceLock::new();
576 WORKER.get_or_init(|| capture_worker::CaptureWorker::new(32))
577}
578
579struct ExtensionCaptureProvider {
580 manager: std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
581 session: crate::tools::activation::SessionId,
582 plugin: String,
583 digest: crate::tools::catalog::SchemaDigest,
584 handle: tokio::runtime::Handle,
585}
586
587impl capture_worker::CaptureProvider for ExtensionCaptureProvider {
588 fn capture(
589 &self,
590 capture: chat_capture::ChatTurnCapture,
591 ) -> std::result::Result<(), capture_worker::CaptureFailure> {
592 let params = memory_context::capture_request_wire(&capture);
593 self.handle
594 .block_on(
595 crate::extensions::lease::ExtensionLeaseCapability::new(
596 self.session.clone(),
597 self.manager.clone(),
598 )
599 .call_exact(
600 &self.plugin,
601 memory_context::MEMORY_CAPTURE_TOOL_NAME,
602 &self.digest,
603 params,
604 ),
605 )
606 .map(|_| ())
607 .map_err(|_| capture_worker::CaptureFailure {
608 code: "provider_call_failed",
609 })
610 }
611
612 fn contains_capture(
613 &self,
614 capture_id: &[u8; 32],
615 ) -> std::result::Result<capture_worker::CaptureCommitState, capture_worker::CaptureFailure>
616 {
617 let capture_id = chat_capture::CaptureId::from_bytes(*capture_id);
618 let response = self
619 .handle
620 .block_on(
621 crate::extensions::lease::ExtensionLeaseCapability::new(
622 self.session.clone(),
623 self.manager.clone(),
624 )
625 .call_exact(
626 &self.plugin,
627 memory_context::MEMORY_CAPTURE_TOOL_NAME,
628 &self.digest,
629 memory_context::capture_query_wire(&capture_id),
630 ),
631 )
632 .map_err(|_| capture_worker::CaptureFailure {
633 code: "provider_query_failed",
634 })?;
635 Ok(
636 if response
637 .get("committed")
638 .and_then(serde_json::Value::as_bool)
639 == Some(true)
640 {
641 capture_worker::CaptureCommitState::Committed
642 } else {
643 capture_worker::CaptureCommitState::Absent
644 },
645 )
646 }
647
648 fn capture_summary(
649 &self,
650 capture: chat_capture::ConversationSummaryCapture,
651 ) -> std::result::Result<(), capture_worker::CaptureFailure> {
652 let params = memory_context::summary_capture_request_wire(&capture);
653 self.handle
654 .block_on(
655 crate::extensions::lease::ExtensionLeaseCapability::new(
656 self.session.clone(),
657 self.manager.clone(),
658 )
659 .call_exact(
660 &self.plugin,
661 memory_context::MEMORY_CAPTURE_TOOL_NAME,
662 &self.digest,
663 params,
664 ),
665 )
666 .map(|_| ())
667 .map_err(|_| capture_worker::CaptureFailure {
668 code: "provider_call_failed",
669 })
670 }
671}
672
673fn terminal_capture_history(
674 lease: &memory_context::MemoryContextLease,
675 messages: &[crate::SharedMessage],
676 started_at: std::time::SystemTime,
677) -> chat_capture::TerminalTurnHistory {
678 use agent_core::core::disclosure::DisclosureClass;
679 let mut items = Vec::new();
680 for message in messages {
681 let Some(role) = message.get("role").and_then(serde_json::Value::as_str) else {
682 continue;
683 };
684 let class = match role {
685 "user" => chat_capture::CaptureContentClass::UserMessage,
686 "assistant" => chat_capture::CaptureContentClass::AssistantFinal,
687 _ => continue,
688 };
689 let text = message
690 .get("content")
691 .and_then(serde_json::Value::as_str)
692 .map(str::to_owned)
693 .unwrap_or_else(|| {
694 message
695 .get("content")
696 .map(ToString::to_string)
697 .unwrap_or_default()
698 });
699 items.push(chat_capture::CanonicalCaptureItem {
700 project_id: lease.project_id.clone(),
701 class,
702 disclosure: DisclosureClass::ModelVisible,
703 sensitivity: chat_capture::Sensitivity::Normal,
704 text,
705 tool_name: None,
706 });
707 }
708 chat_capture::TerminalTurnHistory {
709 project_id: lease.project_id.clone(),
710 session_id: lease.session_id.clone(),
711 turn_id: memory_context::TurnId::parse(&format!("turn-{}", uuid::Uuid::new_v4()))
712 .expect("generated turn id"),
713 turn_ordinal: 0,
714 started_at,
715 completed_at: std::time::SystemTime::now(),
716 outcome: agent_core::TurnOutcome::Completed,
717 items,
718 compaction: None,
719 }
720}
721
722fn memory_provider_id() -> memory_context::ContextProviderId {
723 memory_context::ContextProviderId::parse("axel-memory")
724 .expect("static provider id is always valid")
725}
726
727fn terminal_capture_start(messages: &[crate::SharedMessage]) -> Option<crate::SharedMessage> {
728 messages
729 .iter()
730 .rfind(|message| {
731 message["role"] == "user"
732 && (message["content"].is_string()
733 || message["content"].as_array().is_some_and(|blocks| {
734 blocks.iter().any(|block| block["type"] != "tool_result")
735 }))
736 })
737 .cloned()
738}
739
740fn terminal_capture_messages<'a>(
741 messages: &'a [crate::SharedMessage],
742 start: &crate::SharedMessage,
743) -> Option<&'a [crate::SharedMessage]> {
744 let index = messages
748 .iter()
749 .position(|message| Arc::ptr_eq(message, start))?;
750 Some(&messages[index..])
751}
752
753fn dispatch_completed_terminal_capture(
756 completed: bool,
757 final_history: &std::sync::Mutex<Option<Vec<crate::SharedMessage>>>,
758 state: &std::sync::Mutex<memory_context::SessionMemoryState>,
759 selected: Option<(
760 memory_context::MemoryContextLease,
761 Arc<dyn capture_worker::CaptureProvider>,
762 )>,
763 started_at: std::time::SystemTime,
764 turn_start: Option<&crate::SharedMessage>,
765) -> bool {
766 let history = final_history
767 .lock()
768 .unwrap_or_else(std::sync::PoisonError::into_inner)
769 .take();
770 let (Some(messages), Some((lease, provider))) = (history, selected) else {
771 return false;
772 };
773 if !completed {
774 return false;
775 }
776 let Some(messages) = turn_start.and_then(|start| terminal_capture_messages(&messages, start))
777 else {
778 return false;
779 };
780 let state = state
781 .lock()
782 .unwrap_or_else(std::sync::PoisonError::into_inner);
783 if state
784 .capture_lease_at(std::time::SystemTime::now())
785 .as_ref()
786 != Some(&lease)
787 {
788 return false;
789 }
790 memory_capture_worker()
791 .submit_terminal(
792 &lease,
793 terminal_capture_history(&lease, messages, started_at),
794 memory_context::RetentionClass::Standard,
795 provider,
796 )
797 .unwrap_or(false)
798}
799
800const HTTP_READ_TIMEOUT: Duration = Duration::from_secs(90);
805
806fn build_http_client(read_timeout: Duration) -> reqwest::Result<Client> {
819 Client::builder()
820 .tls_built_in_webpki_certs(true)
821 .tls_built_in_native_certs(true)
822 .connect_timeout(Duration::from_secs(10))
823 .read_timeout(read_timeout)
824 .build()
825}
826
827pub fn build_host_http_client() -> Result<Client> {
831 build_http_client(HTTP_READ_TIMEOUT)
832 .map_err(|e| RuntimeError::Config(format!("Failed to build HTTP client: {}", e)))
833}
834
835pub(crate) struct RuntimeParts {
839 pub host: crate::host::HostParts,
840 pub session_manager: Arc<crate::tools::shell::SessionManager>,
841 pub reaper: Option<(tokio::task::JoinHandle<()>, CancellationToken)>,
842}
843
844impl RuntimeParts {
845 pub(crate) fn with_reaper(host: crate::host::HostParts) -> Self {
847 let session_manager = fresh_session_manager();
848 let cancel = CancellationToken::new();
849 let handle =
850 crate::tools::shell::session::start_reaper(session_manager.clone(), cancel.clone());
851 Self {
852 host,
853 session_manager,
854 reaper: Some((handle, cancel)),
855 }
856 }
857
858 #[cfg_attr(not(any(test, feature = "testing")), allow(dead_code))]
860 pub(crate) fn without_reaper(host: crate::host::HostParts) -> Self {
861 Self {
862 host,
863 session_manager: fresh_session_manager(),
864 reaper: None,
865 }
866 }
867}
868
869fn fresh_session_manager() -> Arc<crate::tools::shell::SessionManager> {
870 let config = crate::tools::shell::ShellConfig::default();
871 crate::tools::shell::SessionManager::new(config)
872}
873
874fn fresh_host_parts() -> Result<crate::host::HostParts> {
878 let client = build_host_http_client()?;
879 let capture_dir = trace::default_capture_dir();
884 let _ = trace::sweep_expired_captures(&capture_dir);
885 Ok(crate::host::HostParts {
886 client,
887 tools: Arc::new(RwLock::new(ToolRegistry::new())),
888 hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
889 credential_source: crate::auth::CredentialSource::Local,
890 token_cache: crate::auth::TokenCache::new(),
891 mcp_runtime: None,
892 extension_runtime: None,
893 capture_dir,
894 progressive_tool_disclosure: false,
895 })
896}
897
898fn canonical_trusted_worker_model(model: &str) -> String {
902 let model = model.trim();
903 if let Some(id) = model.strip_prefix("claude/") {
904 format!("anthropic/{id}")
905 } else if model.starts_with("claude-") {
906 format!("anthropic/{model}")
907 } else {
908 model.to_owned()
909 }
910}
911
912impl Drop for Runtime {
916 fn drop(&mut self) {
917 if let Some(c) = &self.reaper_cancel {
918 c.cancel();
919 }
920 }
921}
922
923impl Runtime {
924 pub async fn new() -> Result<Self> {
925 Ok(Self::from_parts(RuntimeParts::with_reaper(
927 fresh_host_parts()?,
928 )))
929 }
930
931 pub(crate) fn from_parts(parts: RuntimeParts) -> Self {
934 let (auth_token, auth_type, refresh_token, token_expires) =
938 (String::new(), "oauth".to_string(), None, Some(0));
939 let RuntimeParts {
940 host,
941 session_manager,
942 reaper,
943 } = parts;
944 let (reaper_handle, reaper_cancel) = match reaper {
945 Some((h, c)) => (Some(h), Some(c)),
946 None => (None, None),
947 };
948 let mcp_runtime = host.mcp_runtime;
949 let extension_runtime = host.extension_runtime;
950
951 let mut runtime = Runtime {
952 client: host.client,
953 auth: Arc::new(RwLock::new(AuthState {
954 auth_token,
955 auth_type,
956 refresh_token,
957 token_expires,
958 })),
959 model: crate::models::default_model().to_string(),
960 tools: host.tools,
961 system_prompt: None,
962 effective_prompt: None,
963 prompt_generation: 0,
964 prompt_reload_source: None,
965 thinking_budget: 4096,
966 named_level: None,
967 explicit_reasoning: false,
968 codex_request_role: crate::runtime::openai::catalog::CodexRequestRole::Foreground,
969 context_window_override: None,
970 compaction_model: None,
971 compaction_mode: agent_core::compaction::CompactionMode::default(),
972 compaction_exclusions: Vec::new(),
973 remote_summarization_attempts: Arc::new(std::sync::atomic::AtomicU64::new(0)),
974 subagent_registry: Arc::new(Mutex::new(
975 crate::runtime::subagent::SubagentRegistry::new(),
976 )),
977 orchestration: None,
978 event_queue: Arc::new(crate::events::EventQueue::new(1000)),
979 watcher_exit_path: None,
980 max_tool_output: 30000,
981 bash_timeout: 30,
982 bash_max_timeout: 300,
983 subagent_timeout: 300,
984 api_retries: 3,
985 refusal_retries: 2,
986 telemetry_level: crate::runtime::telemetry::TelemetryLevel::Off,
987 telemetry_writer: None,
988 trace_ctx: trace::TraceContext::disabled(),
989 trace_controls: std::sync::Arc::new(trace::TraceControls::new()),
990 memory_context_state: fresh_memory_context_state(),
993 memory_backend: crate::memory_backend::MemoryBinding::legacy_current(),
995 memory_backend_config: None,
996 memory_backend_reconfigure_denied: Arc::new(std::sync::atomic::AtomicBool::new(false)),
997 continuation: Arc::new(Mutex::new(continuation::ContinuationState::default())),
998 pending_history_import_preview: std::sync::Arc::new(std::sync::Mutex::new(None)),
999 history_import_plan: std::sync::Arc::new(std::sync::Mutex::new(None)),
1000 #[cfg(any(test, feature = "testing"))]
1001 capture_provider_for_test: std::sync::Arc::new(std::sync::Mutex::new(None)),
1002 pending_memory_segment: std::sync::Arc::new(std::sync::Mutex::new(None)),
1005 retained_recall_turn: std::sync::Arc::new(std::sync::Mutex::new(None)),
1006 one_shot_trace_writer: std::sync::Arc::new(std::sync::Mutex::new(None)),
1007 capture_dir: host.capture_dir,
1008 cache_diagnostics: false,
1009 cache_ttl: crate::core::config::CacheTtl::default(),
1010 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1011 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1012 last_msg_id: Arc::new(Mutex::new(None)),
1013 session_manager,
1014 hook_bus: host.hook_bus,
1015 reaper_handle,
1016 reaper_cancel,
1017 credential_source: host.credential_source,
1018 token_cache: host.token_cache,
1019 trusted_worker_models: Vec::new(),
1020 progressive_tool_disclosure: host.progressive_tool_disclosure,
1021 activation_confirm: agent_core::config::ActivationConfirm::default(),
1022 delegation_parent: None,
1023 mcp_runtime: None,
1024 mcp_session_scope: None,
1025 extension_runtime: None,
1026 extension_session_scope: None,
1027 turn_budget: crate::runtime::budget::TurnBudget::for_role(
1028 crate::runtime::budget::TurnRole::Foreground,
1029 ),
1030 host_tool_session: fresh_host_tool_session(),
1031 retained_tool_set: Default::default(),
1032 session_id: None,
1033 cwd: None,
1034 env: None,
1035 env_stripped: Vec::new(),
1036 env_warned: Default::default(),
1037 };
1038 if let Some(m) = mcp_runtime {
1042 runtime.install_mcp_runtime(m);
1043 }
1044 if let Some(m) = extension_runtime {
1045 runtime.install_extension_runtime(m);
1046 }
1047 runtime
1048 }
1049
1050
1051 #[cfg(any(test, feature = "testing"))]
1062 pub fn new_headless() -> Self {
1063 let client = build_http_client(HTTP_READ_TIMEOUT)
1064 .expect("reqwest client construction is infallible with built-in roots");
1065 let host = crate::host::HostParts {
1066 client,
1067 tools: Arc::new(RwLock::new(ToolRegistry::new())),
1068 hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
1069 credential_source: crate::auth::CredentialSource::Local,
1070 token_cache: crate::auth::TokenCache::new(),
1071 mcp_runtime: None,
1072 extension_runtime: None,
1073 capture_dir: trace::default_capture_dir(),
1074 progressive_tool_disclosure: false,
1075 };
1076 let mut runtime = Self::from_parts(RuntimeParts::without_reaper(host));
1077 runtime.auth = Arc::new(RwLock::new(AuthState {
1078 auth_token: "test-token".to_string(),
1079 auth_type: "api_key".to_string(),
1080 refresh_token: None,
1081 token_expires: None,
1082 }));
1083 runtime
1084 }
1085
1086 pub fn set_system_prompt(&mut self, prompt: String) {
1087 self.system_prompt = Some(prompt);
1088 }
1089
1090 pub fn system_prompt(&self) -> Option<&str> {
1091 self.system_prompt.as_deref()
1092 }
1093
1094 pub async fn effective_system_prompt(&self) -> Option<String> {
1106 if self.effective_prompt.is_some() {
1107 return self.system_prompt.clone();
1108 }
1109 if self.tools.read().await.get("subagent_start").is_none() {
1110 return self.system_prompt.clone();
1111 }
1112 let Ok(model) = crate::orchestration::canonical_foreground_identity(&self.model) else {
1113 return self.system_prompt.clone();
1114 };
1115 let workflow_mode = match self.reasoning_level() {
1116 agent_core::reasoning::ReasoningLevel::UltraCode => {
1117 Some(agent_core::prompt::WorkflowMode::UltraCode)
1118 }
1119 agent_core::reasoning::ReasoningLevel::Max => {
1120 Some(agent_core::prompt::WorkflowMode::Max)
1121 }
1122 agent_core::reasoning::ReasoningLevel::XHigh => {
1123 Some(agent_core::prompt::WorkflowMode::XHigh)
1124 }
1125 _ => None,
1126 };
1127 let Ok(context) = agent_core::prompt::SelectionContext::new(model, None)
1128 .map(|context| context.with_workflow_mode(workflow_mode))
1129 else {
1130 return self.system_prompt.clone();
1131 };
1132 agent_core::prompt::compose_orchestration_prompt(self.system_prompt.as_deref(), &context)
1133 }
1134
1135 pub fn effective_prompt(&self) -> Option<&agent_core::prompt::PromptStack> {
1136 self.effective_prompt.as_ref()
1137 }
1138
1139 pub fn prompt_generation(&self) -> u64 {
1140 self.prompt_generation
1141 }
1142
1143 pub fn retain_prompt_reload_source(
1144 &mut self,
1145 manifest: PathBuf,
1146 context: agent_core::prompt::SelectionContext,
1147 user_module: Option<agent_core::prompt::PromptModule>,
1148 delegation_policy_digest: Option<String>,
1149 ) {
1150 self.prompt_reload_source = Some(PromptReloadSource {
1151 manifest,
1152 context,
1153 user_module,
1154 delegation_policy_digest,
1155 });
1156 }
1157
1158 pub fn reload_prompt(&mut self) -> std::result::Result<u64, agent_core::prompt::PromptError> {
1160 let source = self.prompt_reload_source.clone().ok_or_else(|| {
1161 agent_core::prompt::PromptError::Invalid("no prompt manifest is active".into())
1162 })?;
1163 let raw = std::fs::read_to_string(&source.manifest).map_err(|_| {
1164 agent_core::prompt::PromptError::Invalid("prompt manifest is unavailable".into())
1165 })?;
1166 let manifest = agent_core::prompt::PromptManifest::parse(&raw)?;
1167 let reload_catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
1168 source.context.model(),
1169 manifest.delegation_catalog_candidates(),
1170 )
1171 .map_err(|error| agent_core::prompt::PromptError::Invalid(error.into()))?;
1172 let candidate_policy_digest = manifest
1173 .delegation_policy(source.context.model().clone(), &reload_catalog)?
1174 .map(|policy| policy.digest());
1175 if candidate_policy_digest != source.delegation_policy_digest {
1176 return Err(agent_core::prompt::PromptError::Invalid(
1177 "hot reload cannot safely change delegation policy".into(),
1178 ));
1179 }
1180 let registry = manifest.registry(source.manifest.parent())?;
1181 let candidate = agent_core::prompt::compile_prompt_stack(
1182 &manifest,
1183 ®istry,
1184 &source.context,
1185 source.user_module,
1186 )?;
1187 self.apply_prompt_stack(candidate)
1188 }
1189
1190 pub fn apply_prompt_stack(
1192 &mut self,
1193 candidate: agent_core::prompt::PromptStack,
1194 ) -> std::result::Result<u64, agent_core::prompt::PromptError> {
1195 if let Some(current) = &self.effective_prompt {
1196 current.validate_hot_reload(&candidate)?;
1197 }
1198 let composed = candidate.composed().to_owned();
1199 self.effective_prompt = Some(candidate);
1200 self.system_prompt = Some(composed);
1201 self.prompt_generation = self.prompt_generation.saturating_add(1);
1202 Ok(self.prompt_generation)
1203 }
1204
1205 pub fn prompt_inspection_json(&self) -> Option<String> {
1206 let mode = self
1207 .orchestration
1208 .as_ref()
1209 .map(|runtime| runtime.enforcement_mode())
1210 .unwrap_or(agent_core::orchestration::EnforcementMode::Off);
1211 self.effective_prompt.as_ref().and_then(|stack| {
1212 serde_json::to_string(&serde_json::json!({
1213 "generation": self.prompt_generation,
1214 "effective": stack.inspect(mode),
1215 "token_estimate": stack.composed().len().div_ceil(4),
1216 }))
1217 .ok()
1218 })
1219 }
1220
1221 pub fn install_mcp_runtime(&mut self, manager: std::sync::Arc<crate::mcp::McpRuntimeManager>) {
1226 self.mcp_session_scope = Some(std::sync::Arc::new(crate::mcp::McpSessionEndGuard::new(
1227 self.host_tool_session.clone(),
1228 std::sync::Arc::clone(&manager),
1229 )));
1230 self.mcp_runtime = Some(manager);
1231 }
1232
1233 pub fn set_turn_budget(&mut self, budget: crate::runtime::budget::TurnBudget) {
1237 self.turn_budget = budget;
1238 }
1239
1240 pub fn turn_budget(&self) -> &crate::runtime::budget::TurnBudget {
1242 &self.turn_budget
1243 }
1244
1245 pub fn install_extension_runtime(
1250 &mut self,
1251 manager: std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
1252 ) {
1253 manager.bind_host_scope(self.host_tool_session.clone());
1257 self.extension_session_scope = Some(std::sync::Arc::new(
1258 crate::extensions::lease::ExtensionSessionEndGuard::new(
1259 self.host_tool_session.clone(),
1260 std::sync::Arc::clone(&manager),
1261 ),
1262 ));
1263 self.extension_runtime = Some(manager);
1264 }
1265
1266 pub fn install_orchestration(
1267 &mut self,
1268 runtime: Arc<crate::orchestration::OrchestrationRuntime>,
1269 ) {
1270 for model in &self.trusted_worker_models {
1271 if let Err(error) = runtime.grant_worker_model(model.as_str()) {
1272 tracing::warn!(
1273 model = model.as_str(),
1274 error = %error,
1275 "failed to apply configured worker-model trust"
1276 );
1277 }
1278 }
1279 self.orchestration = Some(runtime);
1280 }
1281
1282 pub fn install_worker_orchestration(
1288 &mut self,
1289 runtime: Arc<crate::orchestration::OrchestrationRuntime>,
1290 ) {
1291 self.orchestration = Some(runtime);
1292 }
1293
1294 pub fn set_delegation_parent(&mut self, parent: Option<String>) {
1295 self.delegation_parent = parent;
1296 }
1297
1298 pub fn orchestration(&self) -> Option<&Arc<crate::orchestration::OrchestrationRuntime>> {
1299 self.orchestration.as_ref()
1300 }
1301
1302 pub fn grant_worker_model(&self, model: &str) -> std::result::Result<(), String> {
1307 self.orchestration
1308 .as_ref()
1309 .ok_or_else(|| "delegation policy unavailable".to_string())?
1310 .grant_worker_model(model)
1311 }
1312
1313 pub fn set_model(&mut self, model: String) -> Option<ReasoningClamp> {
1317 self.try_set_model(model).ok().flatten()
1318 }
1319
1320 pub fn try_set_model(
1327 &mut self,
1328 model: String,
1329 ) -> std::result::Result<Option<ReasoningClamp>, String> {
1330 let trimmed = model.trim();
1335 let cleaned = trimmed
1336 .split_once(char::is_whitespace)
1337 .and_then(|(_, rest)| {
1338 let rest = rest.trim_start();
1339 let (latency, candidate) = rest.split_once(char::is_whitespace)?;
1340 let millis = latency.strip_suffix("ms")?;
1341 if !millis.is_empty() && millis.chars().all(|c| c.is_ascii_digit()) {
1342 let candidate = candidate.trim();
1343 if !candidate.is_empty() && !candidate.chars().any(char::is_whitespace) {
1344 return Some(candidate);
1345 }
1346 }
1347 None
1348 })
1349 .unwrap_or(trimmed);
1350 if self.prompt_reload_source.is_none()
1356 && self
1357 .orchestration
1358 .as_ref()
1359 .is_some_and(|current| !current.unreconciled_runtime_handles().is_empty())
1360 {
1361 return Err("model change blocked: workers require collection/reconciliation".into());
1362 }
1363 let orchestration_replacement = if self.prompt_reload_source.is_none()
1364 && self.orchestration.is_some()
1365 {
1366 let foreground = crate::orchestration::canonical_foreground_identity(cleaned)
1367 .map_err(|_| "model change blocked: unresolved foreground model".to_string())?;
1368 let replacement = crate::orchestration::OrchestrationRuntime::baseline(
1369 foreground, 8, 64,
1370 )
1371 .map_err(|_| "model change blocked: trusted worker catalog unavailable".to_string())?;
1372 for trusted in &self.trusted_worker_models {
1373 replacement
1374 .grant_worker_model(trusted.as_str())
1375 .map_err(|_| {
1376 "model change blocked: configured worker trust invalid".to_string()
1377 })?;
1378 }
1379 Some(Arc::new(replacement))
1380 } else {
1381 None
1382 };
1383 self.model = cleaned.to_owned();
1384 if let Some(replacement) = orchestration_replacement {
1388 self.orchestration = Some(replacement);
1389 }
1390 if !self.explicit_reasoning {
1396 if let Some(level) =
1397 crate::runtime::openai::catalog::validation::default_level_for_model(cleaned)
1398 {
1399 self.set_reasoning_level(level);
1400 }
1401 }
1402 Ok(self.clamp_reasoning_to_model())
1407 }
1408
1409 fn clamp_reasoning_to_model(&mut self) -> Option<ReasoningClamp> {
1415 use crate::runtime::openai::catalog::validation::{
1416 default_level_for_model, thinking_options_for_model, validate_reasoning_mutation,
1417 };
1418 let model = self.model.as_str();
1419 let from = self.reasoning_level();
1420 if validate_reasoning_mutation(model, from).is_ok() {
1421 return None;
1422 }
1423 let to = default_level_for_model(model)
1424 .filter(|level| validate_reasoning_mutation(model, *level).is_ok())
1425 .or_else(|| {
1426 thinking_options_for_model(model)
1427 .iter()
1428 .filter_map(|opt| agent_core::reasoning::ReasoningLevel::parse(opt))
1429 .find(|level| validate_reasoning_mutation(model, *level).is_ok())
1430 })?;
1431 let explicit = self.explicit_reasoning;
1432 self.set_reasoning_level(to);
1433 self.explicit_reasoning = explicit;
1434 Some(ReasoningClamp { from, to })
1435 }
1436
1437 pub fn set_tools(&mut self, tools: ToolRegistry) {
1438 self.tools = Arc::new(RwLock::new(tools));
1439 }
1440
1441 pub fn subagent_registry(&self) -> &Arc<Mutex<crate::runtime::subagent::SubagentRegistry>> {
1442 &self.subagent_registry
1443 }
1444
1445 pub fn event_queue(&self) -> &Arc<crate::events::EventQueue> {
1446 &self.event_queue
1447 }
1448
1449 pub fn set_event_queue(&mut self, queue: Arc<crate::events::EventQueue>) {
1453 self.event_queue = queue;
1454 }
1455
1456 pub fn hook_bus(&self) -> &Arc<crate::extensions::hooks::HookBus> {
1458 &self.hook_bus
1459 }
1460
1461 pub fn session_manager(&self) -> &Arc<crate::tools::shell::SessionManager> {
1464 &self.session_manager
1465 }
1466
1467 pub fn host_tool_session_id(&self) -> &crate::tools::activation::SessionId {
1471 &self.host_tool_session
1472 }
1473
1474 pub fn set_session_id(&mut self, id: Option<String>) {
1477 self.session_id = id;
1478 }
1479
1480 pub fn session_id(&self) -> Option<&str> {
1481 self.session_id.as_deref()
1482 }
1483
1484 pub fn set_cwd(&mut self, cwd: Option<PathBuf>) {
1487 self.cwd = cwd;
1488 }
1489
1490 pub fn cwd(&self) -> Option<&Path> {
1491 self.cwd.as_deref()
1492 }
1493
1494 pub fn set_env(&mut self, env: Option<crate::session::types::SessionEnv>) {
1497 self.env = env;
1498 }
1499
1500 pub fn set_env_stripped(&mut self, stripped: Vec<String>) {
1501 self.env_stripped = stripped;
1502 }
1503
1504 pub fn env(&self) -> Option<&crate::session::types::SessionEnv> {
1505 self.env.as_ref()
1506 }
1507
1508 pub fn env_stripped(&self) -> &[String] {
1509 &self.env_stripped
1510 }
1511
1512 pub fn tools_shared(&self) -> Arc<RwLock<ToolRegistry>> {
1514 Arc::clone(&self.tools)
1515 }
1516
1517 pub fn model(&self) -> &str {
1518 &self.model
1519 }
1520
1521 pub fn http_client(&self) -> &Client {
1522 &self.client
1523 }
1524
1525 pub fn progressive_tool_disclosure(&self) -> bool {
1527 self.progressive_tool_disclosure
1528 }
1529
1530 pub fn activation_confirm(&self) -> agent_core::config::ActivationConfirm {
1532 self.activation_confirm
1533 }
1534
1535 pub fn set_activation_confirm(&mut self, mode: agent_core::config::ActivationConfirm) {
1538 self.activation_confirm = mode;
1539 }
1540
1541 #[cfg(any(test, feature = "testing"))]
1545 pub fn http_client_pool_id(&self) -> usize {
1546 const _: () = assert!(std::mem::size_of::<Client>() == std::mem::size_of::<usize>());
1547 unsafe { std::mem::transmute_copy::<Client, usize>(&self.client) }
1550 }
1551 pub fn set_thinking_budget(&mut self, budget: u32) {
1552 self.thinking_budget = budget;
1553 self.named_level = Some(agent_core::reasoning::ReasoningLevel::from_legacy_budget(
1555 budget,
1556 ));
1557 self.explicit_reasoning = false;
1559 }
1560
1561 pub fn set_reasoning_level(&mut self, level: agent_core::reasoning::ReasoningLevel) {
1567 self.named_level = Some(level);
1568 if let Some(budget) = level.to_legacy_budget() {
1569 self.thinking_budget = budget;
1570 }
1571 self.explicit_reasoning = false;
1573 }
1574
1575 pub fn set_reasoning_level_explicit(&mut self, level: agent_core::reasoning::ReasoningLevel) {
1580 self.set_reasoning_level(level);
1581 self.explicit_reasoning = true;
1582 }
1583
1584 pub fn restore_session_reasoning(&mut self, thinking_level: &str) -> Option<ReasoningClamp> {
1590 if let Some(level) = agent_core::reasoning::ReasoningLevel::parse(thinking_level) {
1591 self.set_reasoning_level_explicit(level);
1592 } else if let Some(budget) = crate::models::budget_for_thinking_level(thinking_level) {
1593 self.set_thinking_budget_explicit(budget);
1594 } else {
1595 return None;
1596 }
1597 self.clamp_reasoning_to_model()
1598 }
1599
1600 pub fn set_thinking_budget_explicit(&mut self, budget: u32) {
1605 self.thinking_budget = budget;
1606 self.named_level = Some(agent_core::reasoning::ReasoningLevel::from_legacy_budget(
1607 budget,
1608 ));
1609 self.explicit_reasoning = true;
1610 }
1611
1612 pub fn is_reasoning_explicit(&self) -> bool {
1614 self.explicit_reasoning
1615 }
1616
1617 pub fn thinking_budget_raw(&self) -> u32 {
1619 self.thinking_budget
1620 }
1621
1622 pub fn set_reasoning_level_checked(
1627 &mut self,
1628 level: agent_core::reasoning::ReasoningLevel,
1629 ) -> std::result::Result<(), String> {
1630 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
1631 &self.model,
1632 level,
1633 )?;
1634 if self.model.starts_with("anthropic/")
1635 && level == agent_core::reasoning::ReasoningLevel::UltraCode
1636 {
1637 if self.codex_request_role()
1638 != crate::runtime::openai::catalog::ExecutionRole::Foreground
1639 {
1640 return Err("ultracode_requires_foreground".into());
1641 }
1642 if self.effective_prompt.is_some() {
1643 return Err("typed_prompt_manifest_blocks_required_doctrine".into());
1644 }
1645 if self.orchestration.is_none() {
1646 return Err("ultracode_requires_orchestration".into());
1647 }
1648 let tools = self.tools.try_read().map_err(|_| {
1649 "ultracode prerequisite state is busy; refusing mutation".to_string()
1650 })?;
1651 let required = ["subagent_start", "subagent_status", "subagent_collect"];
1652 if !required.iter().all(|name| {
1653 tools
1654 .get(name)
1655 .is_some_and(|tool| tool.extension_id().is_none())
1656 }) {
1657 return Err("ultracode_requires_lifecycle_tools".into());
1658 }
1659 }
1660 self.set_reasoning_level_explicit(level);
1661 Ok(())
1662 }
1663
1664 pub fn set_thinking_budget_checked(&mut self, budget: u32) -> std::result::Result<(), String> {
1671 let level = agent_core::reasoning::ReasoningLevel::from_legacy_budget(budget);
1672 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
1673 &self.model,
1674 level,
1675 )?;
1676 self.set_thinking_budget_explicit(budget);
1677 Ok(())
1678 }
1679
1680 pub fn reasoning_level(&self) -> agent_core::reasoning::ReasoningLevel {
1681 self.named_level.unwrap_or_else(|| {
1682 agent_core::reasoning::ReasoningLevel::from_legacy_budget(self.thinking_budget)
1683 })
1684 }
1685
1686 pub(crate) fn set_codex_request_role(
1687 &mut self,
1688 role: crate::runtime::openai::catalog::CodexRequestRole,
1689 ) {
1690 self.codex_request_role = role;
1691 }
1692
1693 pub(crate) fn codex_request_role(&self) -> crate::runtime::openai::catalog::CodexRequestRole {
1694 self.codex_request_role
1695 }
1696
1697 pub(crate) fn codex_delegation_plan(
1698 model: &str,
1699 level: agent_core::reasoning::ReasoningLevel,
1700 role: crate::runtime::openai::catalog::CodexRequestRole,
1701 ) -> Option<crate::runtime::openai::catalog::CodexExecutionPlan> {
1702 use crate::runtime::openai::catalog::{plan_codex_execution, CodexRequestRole};
1703 if role != CodexRequestRole::Foreground
1704 || level != agent_core::reasoning::ReasoningLevel::Ultra
1705 || !model.starts_with("openai-codex/")
1706 {
1707 return None;
1708 }
1709 plan_codex_execution(model, level, role, None).ok()
1710 }
1711
1712 async fn authorized_anthropic_plan(
1713 &self,
1714 ) -> Result<Option<crate::runtime::openai::catalog::AnthropicExecutionPlan>> {
1715 if !self.model.starts_with("anthropic/") {
1716 return Ok(None);
1717 }
1718 let tools = self.tools.read().await;
1719 let builtin = |name: &str| {
1720 tools
1721 .get(name)
1722 .is_some_and(|tool| tool.extension_id().is_none())
1723 };
1724 let readiness = self
1725 .orchestration
1726 .as_ref()
1727 .and_then(|runtime| runtime.ultracode_readiness(&self.model).ok());
1728 let prerequisites = crate::runtime::openai::catalog::AnthropicPlanPrerequisites {
1729 orchestration_policy: self.orchestration.is_some(),
1730 foreground_worker_authorized: readiness.is_some(),
1731 concurrent_limit: readiness.map_or(0, |limits| limits.0),
1732 total_limit: readiness.map_or(0, |limits| limits.1),
1733 lifecycle_start: builtin("subagent_start"),
1734 lifecycle_status: builtin("subagent_status"),
1735 lifecycle_steer: builtin("subagent_steer"),
1736 lifecycle_collect: builtin("subagent_collect"),
1737 lifecycle_resume: builtin("subagent_resume"),
1738 };
1739 crate::runtime::openai::catalog::plan_anthropic_execution(
1740 &self.model,
1741 self.reasoning_level(),
1742 self.codex_request_role(),
1743 prerequisites,
1744 crate::runtime::openai::catalog::capability_cache::get(&self.model).and_then(|entry| {
1745 match entry.reasoning {
1746 crate::runtime::openai::catalog::ReasoningSupport::AnthropicAdaptive {
1747 adaptive,
1748 } => Some(adaptive),
1749 crate::runtime::openai::catalog::ReasoningSupport::None => Some(false),
1750 _ => None,
1751 }
1752 }),
1753 )
1754 .map(Some)
1755 .map_err(|error| RuntimeError::Config(error.to_string()))
1756 }
1757
1758 async fn validate_request_preflight(&self) -> Result<()> {
1761 self.validate_request_preflight_for(&self.model, self.codex_request_role)
1762 .await
1763 }
1764
1765 pub(crate) async fn validate_session_driver_preflight(&self) -> Result<()> {
1768 self.validate_request_preflight().await
1769 }
1770
1771 async fn validate_request_preflight_for(
1772 &self,
1773 model: &str,
1774 role: crate::runtime::openai::catalog::CodexRequestRole,
1775 ) -> Result<()> {
1776 if self
1780 .memory_backend_reconfigure_denied
1781 .load(std::sync::atomic::Ordering::SeqCst)
1782 {
1783 return Err(RuntimeError::Config(
1784 "memory backend cannot be reconfigured at runtime (including live legacy extension processes); restart required; request denied".into(),
1785 ));
1786 }
1787 let level = self.reasoning_level();
1788 if model.starts_with("anthropic/")
1789 && level == agent_core::reasoning::ReasoningLevel::UltraCode
1790 && self.effective_prompt.is_some()
1791 {
1792 return Err(RuntimeError::Config(
1793 "Anthropic execution plan rejected: typed_prompt_manifest_blocks_required_doctrine"
1794 .into(),
1795 ));
1796 }
1797 if model.starts_with("anthropic/")
1798 && matches!(
1799 level,
1800 agent_core::reasoning::ReasoningLevel::Max
1801 | agent_core::reasoning::ReasoningLevel::UltraCode
1802 | agent_core::reasoning::ReasoningLevel::Ultra
1803 )
1804 {
1805 let tools = self.tools.read().await;
1806 let builtin = |name: &str| {
1807 tools
1808 .get(name)
1809 .is_some_and(|tool| tool.extension_id().is_none())
1810 };
1811 let readiness = self
1812 .orchestration
1813 .as_ref()
1814 .and_then(|runtime| runtime.ultracode_readiness(model).ok());
1815 let prerequisites = crate::runtime::openai::catalog::AnthropicPlanPrerequisites {
1816 orchestration_policy: self.orchestration.is_some(),
1817 foreground_worker_authorized: readiness.is_some(),
1818 concurrent_limit: readiness.map_or(0, |limits| limits.0),
1819 total_limit: readiness.map_or(0, |limits| limits.1),
1820 lifecycle_start: builtin("subagent_start"),
1821 lifecycle_status: builtin("subagent_status"),
1822 lifecycle_steer: builtin("subagent_steer"),
1823 lifecycle_collect: builtin("subagent_collect"),
1824 lifecycle_resume: builtin("subagent_resume"),
1825 };
1826 drop(tools);
1827 let result = crate::runtime::openai::catalog::plan_anthropic_execution(
1828 model,
1829 level,
1830 role,
1831 prerequisites,
1832 crate::runtime::openai::catalog::capability_cache::get(model).and_then(|entry| {
1833 match entry.reasoning {
1834 crate::runtime::openai::catalog::ReasoningSupport::AnthropicAdaptive {
1835 adaptive,
1836 } => Some(adaptive),
1837 crate::runtime::openai::catalog::ReasoningSupport::None => Some(false),
1838 _ => None,
1839 }
1840 }),
1841 );
1842 match result {
1843 Ok(plan) => {
1844 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);
1845 return Ok(());
1846 }
1847 Err(error) => {
1848 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);
1849 return Err(RuntimeError::Config(error.to_string()));
1850 }
1851 }
1852 }
1853 if model.starts_with("openai-codex/") {
1854 let plan =
1855 crate::runtime::openai::catalog::plan_codex_execution(model, level, role, None)
1856 .map_err(|error| {
1857 tracing::debug!(
1858 event = "codex_mode_plan",
1859 qualified_model = %model,
1860 requested_level = %level,
1861 runtime_role = role.as_str(),
1862 decision = "deny",
1863 deny_code = error.code().as_str(),
1864 network_attempted = false,
1865 "Codex request preflight denied"
1866 );
1867 RuntimeError::Config(error.to_string())
1868 })?;
1869
1870 if plan.automatic_delegation() {
1871 if self.orchestration.is_none() {
1872 tracing::debug!(
1873 event = "codex_mode_plan",
1874 qualified_model = %model,
1875 requested_level = %level,
1876 runtime_role = role.as_str(),
1877 decision = "deny",
1878 deny_code = "ultra_requires_orchestration",
1879 network_attempted = false,
1880 "Codex request preflight denied"
1881 );
1882 return Err(RuntimeError::Config(
1883 "Ultra requires an installed orchestration policy".to_string(),
1884 ));
1885 }
1886
1887 let tools = self.tools.read().await;
1888 let required = ["subagent_start", "subagent_status", "subagent_collect"];
1889 let tools_ready = required.iter().all(|name| {
1890 tools
1891 .get(name)
1892 .is_some_and(|tool| tool.extension_id().is_none())
1893 });
1894 if !tools_ready {
1895 tracing::debug!(
1896 event = "codex_mode_plan",
1897 qualified_model = %model,
1898 requested_level = %level,
1899 runtime_role = role.as_str(),
1900 decision = "deny",
1901 deny_code = "ultra_requires_subagent_tools",
1902 network_attempted = false,
1903 "Codex request preflight denied"
1904 );
1905 return Err(RuntimeError::Config(
1906 "Ultra requires built-in subagent_start, subagent_status, and subagent_collect tools"
1907 .to_string(),
1908 ));
1909 }
1910 }
1911 return Ok(());
1912 }
1913
1914 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(model, level)
1915 .map_err(|error| {
1916 tracing::debug!(
1917 event = "codex_mode_plan",
1918 qualified_model = %model,
1919 requested_level = %level,
1920 runtime_role = role.as_str(),
1921 decision = "deny",
1922 deny_code = "unsupported_reasoning_level",
1923 network_attempted = false,
1924 "request preflight denied"
1925 );
1926 RuntimeError::Config(error)
1927 })
1928 }
1929
1930 pub fn set_compaction_model(&mut self, model: Option<String>) {
1931 self.compaction_model = model;
1932 }
1933
1934 pub fn set_compaction_mode(&mut self, mode: agent_core::compaction::CompactionMode) {
1936 self.compaction_mode = mode;
1937 }
1938
1939 pub fn set_compaction_exclusions(
1941 &mut self,
1942 exclude: Vec<agent_core::compaction::ContentClass>,
1943 ) {
1944 self.compaction_exclusions = exclude;
1945 }
1946
1947 pub fn remote_summarization_attempts(&self) -> u64 {
1950 self.remote_summarization_attempts
1951 .load(std::sync::atomic::Ordering::SeqCst)
1952 }
1953
1954 pub fn compaction_policy(&self) -> crate::runtime::compaction::DisclosurePolicy {
1957 crate::runtime::compaction::DisclosurePolicy {
1958 mode: self.compaction_mode,
1959 exclude: self.compaction_exclusions.clone(),
1960 }
1961 }
1962
1963 pub fn set_context_window(&mut self, window: Option<u64>) {
1964 self.context_window_override = window;
1965 }
1966
1967 pub fn compaction_model(&self) -> &str {
1970 self.compaction_model
1971 .as_deref()
1972 .unwrap_or("claude-sonnet-4-6")
1973 }
1974
1975 pub fn context_window(&self) -> u64 {
1976 self.context_window_override
1977 .unwrap_or_else(|| crate::models::context_window_for_model(&self.model))
1978 }
1979
1980 pub async fn assess_context(
1994 &self,
1995 messages: &[crate::SharedMessage],
1996 ) -> context::ContextAssessment {
1997 let system = self.effective_system_prompt().await;
1998 let schema = self.tools.read().await.tools_schema();
1999 let memory_rendered: Option<String> = self
2004 .pending_memory_segment
2005 .lock()
2006 .unwrap_or_else(std::sync::PoisonError::into_inner)
2007 .as_ref()
2008 .map(|segment| segment.rendered_text().to_string());
2009 let memory_contents: Vec<&str> = memory_rendered.as_deref().into_iter().collect();
2010 context::assess(&context::ContextBudgetInputs {
2011 model: &self.model,
2012 provider_window: self.context_window(),
2013 system_prompt: system.as_deref(),
2014 tools_schema: &schema,
2015 messages,
2016 skill_contents: &[],
2017 memory_contents: &memory_contents,
2018 thinking_budget_tokens: self.thinking_budget as u64,
2019 next_tool_result_bytes: self.max_tool_output as u64,
2020 output_reserve_tokens: HelperMethods::max_tokens_for_model(&self.model),
2021 })
2022 }
2023
2024 #[allow(dead_code)] pub(crate) fn hold_memory_contribution(
2042 &self,
2043 contribution: memory_context::MemoryContextContribution,
2044 ) -> std::result::Result<(), memory_context::MemoryContextError> {
2045 let budget = memory_context::memory_budget_tokens(self.context_window())
2046 .ok_or(memory_context::MemoryContextError::RecallBudgetBelowMinimum)?;
2047 memory_context::validate_contribution(
2051 &contribution,
2052 &self.memory_context_project_id(),
2053 budget,
2054 &memory_context::DisclosureGrantSet::model_visible_only(),
2055 )?;
2056 *self
2057 .pending_memory_segment
2058 .lock()
2059 .unwrap_or_else(std::sync::PoisonError::into_inner) =
2060 Some(memory_context::ContextSegment::Memory(contribution));
2061 Ok(())
2062 }
2063
2064 pub(crate) fn clear_memory_contribution(&self) {
2068 *self
2069 .pending_memory_segment
2070 .lock()
2071 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
2072 }
2073
2074 pub fn memory_recall_why(&self) -> Option<memory_context::RecallTurnMetadata> {
2080 self.retained_recall_turn
2081 .lock()
2082 .unwrap_or_else(std::sync::PoisonError::into_inner)
2083 .as_ref()
2084 .map(|retained| retained.why.clone())
2085 }
2086
2087 async fn apply_turn_memory_recall(&self, messages: &mut Vec<crate::SharedMessage>) {
2105 if (self.memory_backend.exclusive() && !self.memory_backend.is_axel())
2106 || self
2107 .memory_backend_reconfigure_denied
2108 .load(std::sync::atomic::Ordering::SeqCst)
2109 {
2110 self.clear_memory_contribution();
2111 tracing::debug!("memory recall unavailable for the selected host backend");
2112 return;
2113 }
2114 let extension_runtime = self.extension_runtime.clone();
2115 let binding = self.memory_backend.clone();
2116 let session = self.host_tool_session.clone();
2117 let outcome = memory_context::resolve_turn_recall(
2118 &self.memory_context_state,
2119 &self.retained_recall_turn,
2120 &self.memory_context_project_id(),
2121 self.context_window(),
2122 messages,
2123 memory_context::RECALL_HARD_TIMEOUT,
2124 move |lease: memory_context::MemoryContextLease,
2125 request: memory_context::RecallRequest| async move {
2126 if binding.is_axel() {
2127 return axel_context::recall(binding, lease, request).await;
2128 }
2129 let Some(manager) = extension_runtime else {
2130 return Err(memory_context::RecallCallError::ProviderUnavailable);
2131 };
2132 let mut parts = lease.provider_id.as_str().splitn(3, ':');
2133 let (Some("extension"), Some(plugin), Some(local)) =
2134 (parts.next(), parts.next(), parts.next())
2135 else {
2136 return Err(memory_context::RecallCallError::ProviderUnavailable);
2137 };
2138 let Some(digest) =
2139 manager.declared_tool_digest(plugin, memory_context::MEMORY_RECALL_TOOL_NAME)
2140 else {
2141 return Err(memory_context::RecallCallError::ProviderUnavailable);
2142 };
2143 let mut response =
2144 crate::extensions::lease::ExtensionLeaseCapability::new(session, manager)
2145 .call_exact(
2146 plugin,
2147 memory_context::MEMORY_RECALL_TOOL_NAME,
2148 &digest,
2149 memory_context::recall_request_wire(&request),
2150 )
2151 .await
2152 .map_err(|_| memory_context::RecallCallError::CallFailed)?;
2153 if response["provider_id"] != local
2156 && response["provider_id"] != lease.provider_id.as_str()
2157 {
2158 return Err(memory_context::RecallCallError::CallFailed);
2159 }
2160 response["provider_id"] = json!(lease.provider_id.as_str());
2161 Ok(response)
2162 },
2163 )
2164 .await;
2165 match outcome {
2166 memory_context::TurnRecallOutcome::Injected
2167 | memory_context::TurnRecallOutcome::ReusedRetained => {
2168 let contribution = self
2169 .retained_recall_turn
2170 .lock()
2171 .unwrap_or_else(std::sync::PoisonError::into_inner)
2172 .as_ref()
2173 .map(|retained| retained.contribution.clone());
2174 if let Some(contribution) = contribution {
2175 *self
2176 .pending_memory_segment
2177 .lock()
2178 .unwrap_or_else(std::sync::PoisonError::into_inner) =
2179 Some(memory_context::ContextSegment::Memory(contribution));
2180 }
2181 }
2182 memory_context::TurnRecallOutcome::NotEligible => {
2183 self.clear_memory_contribution();
2184 }
2185 memory_context::TurnRecallOutcome::SkippedOpen(reason) => {
2186 self.clear_memory_contribution();
2187 tracing::debug!(
2190 reason = reason.as_str(),
2191 "memory recall skipped; turn proceeds without memory"
2192 );
2193 }
2194 }
2195 }
2196
2197 #[cfg(any(test, feature = "testing"))]
2207 pub async fn apply_turn_memory_recall_for_harness(
2208 &self,
2209 messages: &mut Vec<crate::SharedMessage>,
2210 ) {
2211 self.apply_turn_memory_recall(messages).await;
2212 }
2213
2214 #[cfg(any(test, feature = "testing"))]
2218 pub fn capture_completed_turn_for_harness(
2219 &self,
2220 messages: Vec<crate::SharedMessage>,
2221 turn_ordinal: u64,
2222 ) -> std::result::Result<(), &'static str> {
2223 let started_at = std::time::SystemTime::now();
2224 let lease = self
2225 .memory_context_lock()
2226 .capture_lease_at(started_at)
2227 .ok_or("capture lease unavailable")?;
2228 let provider = self
2229 .extension_capture_provider(&lease)
2230 .ok_or("capture provider unavailable")?;
2231 let mut history = terminal_capture_history(&lease, &messages, started_at);
2232 history.turn_ordinal = turn_ordinal;
2233 memory_capture_worker()
2234 .submit_terminal(
2235 &lease,
2236 history,
2237 memory_context::RetentionClass::Standard,
2238 provider,
2239 )
2240 .map_err(|_| "capture build failed")?;
2241 Ok(())
2242 }
2243
2244 #[cfg(any(test, feature = "testing"))]
2247 pub fn capture_compaction_summary_for_harness(
2248 &self,
2249 first_turn_ordinal: u64,
2250 last_turn_ordinal: u64,
2251 source_digest: &str,
2252 source_message_count: usize,
2253 summary_text: &str,
2254 ) -> std::result::Result<(), &'static str> {
2255 let summarized_at = std::time::SystemTime::now();
2256 let lease = self
2257 .memory_context_lock()
2258 .capture_lease_at(summarized_at)
2259 .ok_or("capture lease unavailable")?;
2260 let source_digest = chat_capture::MessageRangeDigest::from_hex(source_digest)
2261 .ok_or("invalid source digest")?;
2262 let provider = self
2263 .extension_capture_provider(&lease)
2264 .ok_or("capture provider unavailable")?;
2265 let source = chat_capture::CompactionSource {
2266 schema: chat_capture::CompactionSchemaVersion::V1,
2267 project_id: lease.project_id.clone(),
2268 source_session_id: lease.session_id.clone(),
2269 first_turn_ordinal,
2270 last_turn_ordinal,
2271 source_digest,
2272 summary_origin: chat_capture::CompactionSummaryOrigin::LocalOnly,
2273 prompt_stack_digest: chat_capture::PromptStackDigest::from_bytes([7; 32]),
2274 redaction: chat_capture::RedactionPolicy::HostRedacted,
2275 content_classes: vec![agent_core::compaction::ContentClass::UserText],
2276 summarized_at,
2277 };
2278 memory_capture_worker()
2279 .submit_summary(
2280 &lease,
2281 source,
2282 source_message_count,
2283 summary_text,
2284 agent_core::compaction::RedactionPolicy::PolicyExclusions,
2285 memory_context::RetentionClass::Standard,
2286 provider,
2287 )
2288 .map_err(|_| "summary capture build failed")?;
2289 Ok(())
2290 }
2291
2292 #[cfg(any(test, feature = "testing"))]
2295 pub fn cancel_memory_forwarding_for_harness(&self) {
2296 self.clear_memory_contribution();
2297 self.memory_context_disable();
2298 }
2299
2300 #[cfg(any(test, feature = "testing"))]
2302 pub fn extension_lease_count_for_harness(&self) -> usize {
2303 self.extension_runtime
2304 .as_ref()
2305 .map_or(0, |manager| manager.lease_count())
2306 }
2307
2308 pub(crate) fn inherit_memory_backend(&mut self, binding: crate::memory_backend::MemoryBinding) {
2312 self.memory_backend = binding.fork_for_worker();
2313 }
2314
2315 pub fn apply_memory_backend_config(&mut self, config: &crate::config::MemoryBackendConfig) {
2317 if let Some(selected) = &self.memory_backend_config {
2318 if selected != config {
2319 self.memory_backend_reconfigure_denied
2320 .store(true, std::sync::atomic::Ordering::SeqCst);
2321 tracing::warn!("memory backend change denied: live extension processes cannot be reconfigured at runtime; original binding retained; restart required");
2322 self.memory_context_disable();
2323 self.clear_memory_contribution();
2324 }
2325 return;
2326 }
2327 if config.kind != crate::config::MemoryBackendKind::Legacy {
2328 self.memory_context_disable();
2329 }
2330 self.memory_backend = crate::memory_backend::MemoryBinding::from_config_with_cwd(
2334 config,
2335 self.cwd.clone().or_else(|| std::env::current_dir().ok()),
2336 );
2337 self.memory_backend_config = Some(config.clone());
2338 }
2339
2340 #[cfg(test)]
2341 #[allow(dead_code)] pub(crate) fn memory_backend_for_test(&self) -> crate::memory_backend::MemoryBinding {
2343 self.memory_backend.clone()
2344 }
2345
2346 pub fn memory_backend_is_axel(&self) -> bool {
2351 self.memory_backend.is_axel()
2352 }
2353
2354 pub fn memory_backend_exclusive(&self) -> bool {
2355 self.memory_backend.exclusive()
2356 }
2357
2358 fn memory_context_project_id(&self) -> memory_context::ProjectId {
2359 if self.memory_backend.exclusive() {
2360 self.memory_backend
2361 .scope()
2362 .ok()
2363 .and_then(|scope| memory_context::ProjectId::parse(scope.key()).ok())
2364 .unwrap_or_else(|| {
2365 memory_context::ProjectId::parse("project-unresolved").expect("static id")
2366 })
2367 } else {
2368 memory_project_id()
2369 }
2370 }
2371
2372 pub fn memory_tool_capability(&self) -> Option<memory_context::MemoryContextCapability> {
2373 if !self.memory_backend.exclusive() {
2374 return None;
2375 }
2376 let runtime = self.clone();
2377 Some(memory_context::MemoryContextCapability::control_only(
2378 self.memory_context_state.clone(),
2379 self.memory_context_project_id(),
2380 memory_context::ContextProviderId::parse(axel_context::PROVIDER_ID).expect("static id"),
2381 Arc::new(move || runtime.memory_context_disable()),
2382 ))
2383 }
2384
2385 pub fn apply_config(&mut self, config: &crate::config::SynapsConfig) {
2389 self.apply_config_inner(config, true);
2390 }
2391
2392 pub(crate) fn apply_config_keep_tools(&mut self, config: &crate::config::SynapsConfig) {
2399 self.apply_config_inner(config, false);
2400 }
2401
2402 fn apply_config_inner(&mut self, config: &crate::config::SynapsConfig, disable_tools: bool) {
2403 self.apply_memory_backend_config(&config.memory_backend);
2405 if (self.memory_backend.exclusive() && !self.memory_backend.is_axel())
2406 || self
2407 .memory_backend_reconfigure_denied
2408 .load(std::sync::atomic::Ordering::SeqCst)
2409 {
2410 self.memory_context_disable();
2412 self.clear_memory_contribution();
2413 *self
2414 .retained_recall_turn
2415 .lock()
2416 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
2417 }
2418 if let Some(ref model) = config.model {
2419 self.set_model(model.clone());
2420 }
2421 if let Some(level) = config.thinking_level {
2424 self.set_reasoning_level_explicit(level);
2425 } else if let Some(budget) = config.thinking_budget {
2426 self.set_thinking_budget_explicit(budget);
2427 }
2428 if let Some(clamp) = self.clamp_reasoning_to_model() {
2431 tracing::warn!(
2432 from = clamp.from.as_str(),
2433 to = clamp.to.as_str(),
2434 model = %self.model,
2435 "configured reasoning level not supported by model; clamped"
2436 );
2437 }
2438 self.context_window_override = config.context_window;
2439 self.continuation
2440 .lock()
2441 .unwrap_or_else(std::sync::PoisonError::into_inner)
2442 .config = config.context_management;
2443 self.compaction_model = config.compaction_model.clone();
2444 self.compaction_mode = config.compaction_mode;
2445 self.compaction_exclusions = config.compaction_exclude.clone();
2446 self.max_tool_output = config.max_tool_output;
2447 self.bash_timeout = config.bash_timeout;
2448 self.bash_max_timeout = config.bash_max_timeout;
2449 self.subagent_timeout = config.subagent_timeout;
2450 self.api_retries = config.api_retries;
2451 self.refusal_retries = config.refusal_retries;
2452 self.telemetry_level =
2453 crate::runtime::telemetry::TelemetryLevel::from_str_key(&config.telemetry);
2454 self.sync_observability();
2455 self.cache_diagnostics = config.cache_diagnostics;
2456 self.cache_ttl = config.cache_ttl;
2457 self.progressive_tool_disclosure = config.progressive_tool_disclosure;
2458 self.activation_confirm = config.tools_activation_confirm;
2459 tracing::info!(
2460 mode = config.tools_activation_confirm.as_str(),
2461 auto_approve_confirms = config.server.auto_approve_confirms,
2462 "tools.activation_confirm: model-initiated activate_tools policy"
2463 );
2464 self.trusted_worker_models = config
2465 .favorite_models
2466 .iter()
2467 .filter_map(|model| {
2468 let canonical = canonical_trusted_worker_model(model);
2469 match agent_core::prompt::QualifiedModelId::parse(canonical) {
2470 Ok(model) => Some(model),
2471 Err(_) => {
2472 tracing::warn!(
2473 model = model.as_str(),
2474 "ignoring invalid favorite worker-model identity"
2475 );
2476 None
2477 }
2478 }
2479 })
2480 .collect();
2481 self.trusted_worker_models.sort();
2482 self.trusted_worker_models.dedup();
2483 if let Some(orchestration) = &self.orchestration {
2484 for model in &self.trusted_worker_models {
2485 if let Err(error) = orchestration.grant_worker_model(model.as_str()) {
2486 tracing::warn!(
2487 model = model.as_str(),
2488 error = %error,
2489 "failed to apply configured worker-model trust"
2490 );
2491 }
2492 }
2493 }
2494 self.apply_auth_config(config);
2495
2496 if disable_tools && !config.disabled_tools.is_empty() {
2500 if let Ok(mut reg) = self.tools.try_write() {
2501 reg.disable(&config.disabled_tools);
2502 }
2503 }
2504 }
2505
2506 pub fn apply_auth_config(&mut self, config: &crate::config::SynapsConfig) {
2514 let new_source = config.auth.credential_source();
2515 if new_source != self.credential_source {
2516 self.token_cache.invalidate("anthropic");
2517 }
2518 self.credential_source = new_source;
2519 if self.credential_source.is_remote() {
2520 if let Ok(mut auth) = self.auth.try_write() {
2521 AuthMethods::scrub_for_remote(&mut auth);
2522 }
2523 }
2524 crate::auth::set_global_broker(crate::auth::broker_from_source(
2529 &self.credential_source,
2530 &self.token_cache,
2531 self.client.clone(),
2532 ));
2533 }
2534
2535 pub fn thinking_budget(&self) -> u32 {
2536 self.thinking_budget
2537 }
2538
2539 pub fn max_tool_output(&self) -> usize {
2540 self.max_tool_output
2541 }
2542
2543 pub fn bash_timeout(&self) -> u64 {
2544 self.bash_timeout
2545 }
2546
2547 pub fn bash_max_timeout(&self) -> u64 {
2548 self.bash_max_timeout
2549 }
2550
2551 pub fn subagent_timeout(&self) -> u64 {
2552 self.subagent_timeout
2553 }
2554
2555 pub fn api_retries(&self) -> u32 {
2556 self.api_retries
2557 }
2558
2559 pub fn set_max_tool_output(&mut self, v: usize) {
2560 self.max_tool_output = v;
2561 }
2562
2563 pub fn set_bash_timeout(&mut self, v: u64) {
2564 self.bash_timeout = v;
2565 }
2566
2567 pub fn set_bash_max_timeout(&mut self, v: u64) {
2568 self.bash_max_timeout = v;
2569 }
2570
2571 pub fn set_subagent_timeout(&mut self, v: u64) {
2572 self.subagent_timeout = v;
2573 }
2574
2575 pub fn set_api_retries(&mut self, v: u32) {
2576 self.api_retries = v;
2577 }
2578
2579 pub fn telemetry_level(&self) -> crate::runtime::telemetry::TelemetryLevel {
2580 self.telemetry_level
2581 }
2582
2583 pub fn set_telemetry_level(&mut self, level: crate::runtime::telemetry::TelemetryLevel) {
2584 self.telemetry_level = level;
2585 self.sync_observability();
2586 }
2587
2588 fn sync_observability(&mut self) {
2595 if self.telemetry_level.enabled() {
2596 if self.telemetry_writer.is_none() {
2597 let writer = crate::runtime::telemetry::TelemetryWriter::new(
2598 crate::runtime::telemetry::WriterOptions::default(),
2599 );
2600 self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
2601 crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
2602 ));
2603 self.telemetry_writer = Some(writer);
2604 }
2605 } else {
2606 self.telemetry_writer = None;
2610 self.trace_ctx = trace::TraceContext::disabled();
2611 }
2612 }
2613
2614 pub fn trace_context(&self) -> trace::TraceContext {
2616 self.trace_ctx.clone()
2617 }
2618
2619 pub(crate) fn effective_trace_context(&self) -> trace::TraceContext {
2640 match self.trace_controls.consume() {
2641 None => self.trace_ctx.clone(),
2642 Some(with_content) => {
2643 let base = if self.trace_ctx.enabled() {
2644 self.trace_ctx.clone()
2645 } else {
2646 let writer = crate::runtime::telemetry::TelemetryWriter::new(
2647 crate::runtime::telemetry::WriterOptions::default(),
2648 );
2649 *self
2653 .one_shot_trace_writer
2654 .lock()
2655 .expect("one-shot trace writer lock poisoned") = Some(writer.clone());
2656 self.trace_ctx
2657 .fork_with_sink(Arc::new(crate::runtime::telemetry::WriterTraceSink::new(
2658 writer,
2659 )))
2660 .with_one_shot_request_gate()
2661 };
2662 if with_content {
2663 base.with_content_capture(Arc::new(trace::ContentCapture::new(
2664 self.capture_dir.clone(),
2665 )))
2666 } else {
2667 base
2668 }
2669 }
2670 }
2671 }
2672
2673 pub fn trace_arm_next(&self, with_content: bool) {
2677 self.trace_controls.arm_next(with_content);
2678 }
2679
2680 pub fn trace_status(&self) -> trace::TraceStatusReport {
2685 let _ = trace::sweep_expired_captures(&self.capture_dir);
2686 trace::TraceStatusReport {
2687 persistent_enabled: self.telemetry_writer.is_some(),
2688 arm: self.trace_controls.peek(),
2689 trace_path: crate::runtime::telemetry::default_trace_log_path(),
2690 writer_stats: self.telemetry_writer.as_ref().map(|w| w.stats()),
2691 degraded_records: self.trace_ctx.degraded_records(),
2692 }
2693 }
2694
2695 fn memory_context_lock(&self) -> std::sync::MutexGuard<'_, memory_context::SessionMemoryState> {
2702 self.memory_context_state
2703 .lock()
2704 .unwrap_or_else(std::sync::PoisonError::into_inner)
2705 }
2706
2707 pub fn memory_context_status(&self) -> memory_context::MemoryContextStatus {
2710 self.memory_context_lock().status()
2711 }
2712
2713 pub fn memory_context_disable(&self) -> memory_context::MemoryContextStatus {
2727 let (status, bound, session) = {
2728 let mut state = self.memory_context_lock();
2729 let session = state.session_id().clone();
2730 let bound = state.bound_provider_ids();
2731 let status = match memory_context::apply_memory_context_action(
2732 &mut state,
2733 memory_context::AuthorizedMemoryAction::Disable {
2734 session: session.clone(),
2735 },
2736 ) {
2737 Ok(status) => status,
2738 Err(_) => state.status(),
2742 };
2743 (status, bound, session)
2744 };
2745 memory_context::emit_memory_observability_event(
2748 &memory_context::MemoryObservabilityEvent::context_disabled(
2749 &session,
2750 &self.memory_context_project_id(),
2751 ),
2752 );
2753 self.revoke_memory_provider_leases(&bound);
2757 status
2758 }
2759
2760 fn revoke_memory_provider_leases(&self, bound: &[memory_context::ContextProviderId]) {
2768 let Some(extension_runtime) = &self.extension_runtime else {
2769 return;
2770 };
2771 for provider in bound {
2772 let mut parts = provider.as_str().splitn(3, ':');
2773 if let (Some("extension"), Some(plugin), Some(_local_id)) =
2774 (parts.next(), parts.next(), parts.next())
2775 {
2776 if !plugin.is_empty() {
2777 extension_runtime.revoke_plugin_lease(&self.host_tool_session, plugin);
2778 }
2779 }
2780 }
2781 }
2782
2783 fn resolve_memory_provider(
2803 &self,
2804 requested: Option<&str>,
2805 ) -> std::result::Result<memory_context::ContextProviderId, memory_context::MemoryContextError>
2806 {
2807 use crate::extensions::context_provider as ext_cp;
2808 if (self.memory_backend.exclusive() && !self.memory_backend.is_axel())
2809 || self
2810 .memory_backend_reconfigure_denied
2811 .load(std::sync::atomic::Ordering::SeqCst)
2812 {
2813 return Err(memory_context::MemoryContextError::ProviderNotRegistered);
2815 }
2816 if self.memory_backend.is_axel() {
2817 self.memory_backend
2818 .validate_axel_config()
2819 .map_err(|_| memory_context::MemoryContextError::ProviderNotRegistered)?;
2820 if requested.is_some_and(|id| id != axel_context::PROVIDER_ID) {
2821 return Err(memory_context::MemoryContextError::ProviderNotRegistered);
2822 }
2823 return memory_context::ContextProviderId::parse(axel_context::PROVIDER_ID);
2824 }
2825 let Some(extension_runtime) = &self.extension_runtime else {
2826 return Ok(memory_provider_id());
2827 };
2828 let requested_id = match requested {
2829 None => None,
2830 Some(raw) => Some(
2833 ext_cp::ContextProviderId::parse(raw)
2834 .map_err(|_| memory_context::MemoryContextError::ProviderNotRegistered)?,
2835 ),
2836 };
2837 match extension_runtime.resolve_context_provider(requested_id.as_ref()) {
2838 Ok(descriptor) => {
2839 memory_context::ContextProviderId::parse(&descriptor.runtime_address())
2840 }
2841 Err(ext_cp::ContextProviderLookupError::NotRegistered) => {
2842 Err(memory_context::MemoryContextError::ProviderNotRegistered)
2843 }
2844 Err(ext_cp::ContextProviderLookupError::Ambiguous) => {
2845 Err(memory_context::MemoryContextError::ProviderAmbiguous)
2846 }
2847 }
2848 }
2849
2850 pub(crate) fn memory_context_enable(
2858 &self,
2859 mode: memory_context::MemoryContextMode,
2860 proof: memory_context::UserIntentProof,
2861 ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
2862 {
2863 self.memory_context_enable_resolved(mode, proof, None)
2864 }
2865
2866 pub(crate) fn memory_context_enable_resolved(
2871 &self,
2872 mode: memory_context::MemoryContextMode,
2873 proof: memory_context::UserIntentProof,
2874 requested_provider: Option<&str>,
2875 ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
2876 {
2877 let mut state = self.memory_context_lock();
2878 let lease = self.grant_command_memory_lease(&state, mode, proof, requested_provider)?;
2879 let enabled_event = memory_context::MemoryObservabilityEvent::context_enabled(&lease);
2883 let result = memory_context::apply_memory_context_action(
2884 &mut state,
2885 memory_context::AuthorizedMemoryAction::Enable { lease },
2886 );
2887 if result.is_ok() {
2888 memory_context::emit_memory_observability_event(&enabled_event);
2889 }
2890 result
2891 }
2892
2893 pub(crate) fn memory_context_recall_once(
2898 &self,
2899 proof: memory_context::UserIntentProof,
2900 ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
2901 {
2902 let mut state = self.memory_context_lock();
2903 let lease = self.grant_command_memory_lease(
2904 &state,
2905 memory_context::MemoryContextMode::RecallOnce,
2906 proof,
2907 None,
2908 )?;
2909 let enabled_event = memory_context::MemoryObservabilityEvent::context_enabled(&lease);
2912 let result = memory_context::apply_memory_context_action(
2913 &mut state,
2914 memory_context::AuthorizedMemoryAction::RecallOnce { lease },
2915 );
2916 if result.is_ok() {
2917 memory_context::emit_memory_observability_event(&enabled_event);
2918 }
2919 result
2920 }
2921
2922 fn grant_command_memory_lease(
2929 &self,
2930 state: &memory_context::SessionMemoryState,
2931 mode: memory_context::MemoryContextMode,
2932 proof: memory_context::UserIntentProof,
2933 requested_provider: Option<&str>,
2934 ) -> std::result::Result<memory_context::MemoryContextLease, memory_context::MemoryContextError>
2935 {
2936 let provider_id = self.resolve_memory_provider(requested_provider)?;
2937 memory_context::MemoryContextLease::grant(
2938 memory_context::MemoryLeaseId::parse(&format!("memctx-cmd-{}", uuid::Uuid::new_v4()))?,
2939 state.session_id().clone(),
2940 self.memory_context_project_id(),
2941 provider_id,
2942 mode,
2943 memory_context::CapturePolicy::default(),
2944 memory_context::RecallPolicy::default(),
2945 proof,
2946 std::time::SystemTime::now(),
2947 None,
2948 )
2949 }
2950
2951 #[cfg(test)]
2952 pub(crate) fn set_capture_provider_for_test(
2953 &self,
2954 provider: std::sync::Arc<dyn capture_worker::CaptureProvider>,
2955 ) {
2956 *self
2957 .capture_provider_for_test
2958 .lock()
2959 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(provider);
2960 }
2961
2962 pub(crate) fn compaction_capture_lease_at(
2963 &self,
2964 now: std::time::SystemTime,
2965 ) -> Option<memory_context::MemoryContextLease> {
2966 self.memory_context_lock().capture_lease_at(now)
2967 }
2968
2969 pub(crate) fn submit_compaction_summary_capture(
2970 &self,
2971 capture_lease: Option<memory_context::MemoryContextLease>,
2972 current: &agent_core::session::Session,
2973 api_messages: &[crate::SharedMessage],
2974 outcome: &compaction::CompactionOutcome,
2975 summarized_at: std::time::SystemTime,
2976 ) {
2977 let Some(lease) = capture_lease else {
2978 return;
2979 };
2980 let Ok(source_session_id) = memory_context::SessionId::parse(¤t.id) else {
2981 return;
2982 };
2983 if api_messages.is_empty() {
2988 return;
2989 }
2990 let first_turn_ordinal = 0;
2991 let last_turn_ordinal = (api_messages.len() - 1) as u64;
2992 let Some(source_digest) = chat_capture::MessageRangeDigest::from_hex(
2993 &agent_core::compaction::message_range_digest(api_messages),
2994 ) else {
2995 return;
2996 };
2997 let Some(prompt_stack_digest) =
2998 chat_capture::PromptStackDigest::from_hex(&outcome.prompt_stack_digest)
2999 else {
3000 return;
3001 };
3002 let summary_origin = if outcome.local_only {
3003 chat_capture::CompactionSummaryOrigin::LocalOnly
3004 } else {
3005 chat_capture::CompactionSummaryOrigin::Provider {
3006 provider_id: outcome.summary_provider.clone(),
3007 model_id: outcome.summary_model.clone(),
3008 }
3009 };
3010 let content_classes = outcome.included_classes.clone();
3011 let source = chat_capture::CompactionSource {
3012 schema: chat_capture::CompactionSchemaVersion::V1,
3013 project_id: lease.project_id.clone(),
3014 source_session_id,
3015 first_turn_ordinal,
3016 last_turn_ordinal,
3017 source_digest,
3018 summary_origin,
3019 prompt_stack_digest,
3020 redaction: match outcome.redaction_policy {
3021 agent_core::compaction::RedactionPolicy::TruncationOnly => {
3022 chat_capture::RedactionPolicy::None
3023 }
3024 agent_core::compaction::RedactionPolicy::PolicyExclusions => {
3025 chat_capture::RedactionPolicy::HostRedacted
3026 }
3027 },
3028 content_classes,
3029 summarized_at,
3030 };
3031
3032 #[cfg(any(test, feature = "testing"))]
3033 let test_provider = self
3034 .capture_provider_for_test
3035 .lock()
3036 .unwrap_or_else(std::sync::PoisonError::into_inner)
3037 .clone();
3038 #[cfg(not(any(test, feature = "testing")))]
3039 let test_provider: Option<std::sync::Arc<dyn capture_worker::CaptureProvider>> = None;
3040 let provider = test_provider.or_else(|| self.extension_capture_provider(&lease));
3041 if let Some(provider) = provider {
3042 let _ = memory_capture_worker().submit_summary(
3043 &lease,
3044 source,
3045 api_messages.len(),
3046 &outcome.summary_text,
3047 outcome.redaction_policy,
3048 memory_context::RetentionClass::Standard,
3049 provider,
3050 );
3051 }
3052 }
3053
3054 fn extension_capture_provider(
3055 &self,
3056 lease: &memory_context::MemoryContextLease,
3057 ) -> Option<std::sync::Arc<dyn capture_worker::CaptureProvider>> {
3058 if (self.memory_backend.exclusive() && !self.memory_backend.is_axel())
3059 || self
3060 .memory_backend_reconfigure_denied
3061 .load(std::sync::atomic::Ordering::SeqCst)
3062 {
3063 return None;
3064 }
3065 if self.memory_backend.is_axel() {
3066 if lease.provider_id.as_str() != axel_context::PROVIDER_ID
3067 || lease.project_id != self.memory_context_project_id()
3068 {
3069 return None;
3070 }
3071 return Some(Arc::new(axel_context::AxelCaptureProvider {
3072 binding: self.memory_backend.clone(),
3073 state: self.memory_context_state.clone(),
3074 lease: lease.clone(),
3075 }));
3076 }
3077 let manager = self.extension_runtime.clone()?;
3078 let mut parts = lease.provider_id.as_str().splitn(3, ':');
3079 let (Some("extension"), Some(plugin), Some(_)) = (parts.next(), parts.next(), parts.next())
3080 else {
3081 return None;
3082 };
3083 let digest =
3084 manager.declared_tool_digest(plugin, memory_context::MEMORY_CAPTURE_TOOL_NAME)?;
3085 let handle = tokio::runtime::Handle::try_current().ok()?;
3086 Some(std::sync::Arc::new(ExtensionCaptureProvider {
3087 manager,
3088 session: self.host_tool_session.clone(),
3089 plugin: plugin.to_owned(),
3090 digest,
3091 handle,
3092 }))
3093 }
3094
3095 pub fn memory_history_preview(
3099 &self,
3100 ) -> std::result::Result<memory_history::HistoryImportPreview, memory_history::HistoryImportError>
3101 {
3102 let host = memory_history::HistoryImportHostState::from_current_host()?;
3103 let mut io = memory_history::CanonicalHistoryMetadataIo::new();
3104 let preview = memory_history::preview_history_import(&host, &mut io)?;
3105 *self
3106 .pending_history_import_preview
3107 .lock()
3108 .expect("history preview mutex poisoned") = Some(preview.clone());
3109 Ok(preview)
3110 }
3111
3112 pub(crate) fn memory_history_confirm(
3116 &self,
3117 ) -> std::result::Result<memory_history::HistoryImportReport, memory_history::HistoryImportError>
3118 {
3119 let preview = self
3120 .pending_history_import_preview
3121 .lock()
3122 .expect("history preview mutex poisoned")
3123 .take()
3124 .ok_or(memory_history::HistoryImportError::ConsentRequired)?;
3125 let intent = memory_history::ConfirmedUserIntent::from_explicit_command(
3126 memory_context::mint_explicit_command_id(),
3127 );
3128 let mut io = memory_history::CanonicalHistoryMetadataIo::new();
3129 match memory_history::authorize_history_import(
3130 preview,
3131 memory_history::HistoryImportConsent::Confirmed(intent),
3132 memory_history::RequestAuthority::UserFrontend,
3133 &mut io,
3134 )? {
3135 memory_history::HistoryImportOutcome::Ready(plan) => {
3136 *self
3137 .history_import_plan
3138 .lock()
3139 .expect("history plan mutex poisoned") = Some(plan.clone());
3140 let lease = self
3141 .memory_context_lock()
3142 .capture_lease_at(std::time::SystemTime::now())
3143 .ok_or(memory_history::HistoryImportError::CaptureLeaseUnavailable)?;
3144 let provider = self
3147 .extension_capture_provider(&lease)
3148 .ok_or(memory_history::HistoryImportError::CaptureProviderUnavailable)?;
3149 let sessions_dir = agent_core::config::get_active_config_dir().join("sessions");
3150 memory_history::import_history_from_dir(
3151 &plan,
3152 &lease,
3153 &sessions_dir,
3154 memory_history::IMPORT_BATCH_MAX_RECORDS,
3155 memory_capture_worker(),
3156 provider,
3157 )
3158 }
3159 memory_history::HistoryImportOutcome::Declined => {
3160 Err(memory_history::HistoryImportError::ConsentRequired)
3161 }
3162 }
3163 }
3164
3165 pub(crate) fn memory_history_decline(&self) -> bool {
3167 self.pending_history_import_preview
3168 .lock()
3169 .expect("history preview mutex poisoned")
3170 .take()
3171 .is_some()
3172 }
3173
3174 #[cfg(test)]
3176 pub(crate) fn memory_durable_proof_for_test(&self) -> Option<memory_context::UserIntentProof> {
3177 self.memory_context_lock().durable_proof().cloned()
3178 }
3179
3180 #[cfg(test)]
3182 pub(crate) fn memory_one_shot_proof_for_test(&self) -> Option<memory_context::UserIntentProof> {
3183 self.memory_context_lock().one_shot_pending_proof().cloned()
3184 }
3185
3186 #[cfg(test)]
3189 pub(crate) fn memory_bound_providers_for_test(&self) -> Vec<memory_context::ContextProviderId> {
3190 self.memory_context_lock().bound_provider_ids()
3191 }
3192
3193 pub fn context_report(&self, history: Option<&[crate::SharedMessage]>) -> trace::ContextReport {
3202 use trace::ReportValue;
3203 let (history_messages, history_bytes) = match history {
3204 Some(msgs) => (
3205 ReportValue::Count(msgs.len() as u64),
3206 ReportValue::Count(
3207 msgs.iter()
3208 .map(|m| {
3209 serde_json::to_vec(&**m)
3210 .map(|v| v.len() as u64)
3211 .unwrap_or(0)
3212 })
3213 .sum(),
3214 ),
3215 ),
3216 None => (ReportValue::Unavailable, ReportValue::Unavailable),
3217 };
3218 let tool_count = match self.tools.try_read() {
3221 Ok(tools) => ReportValue::Count(tools.iter_tools_sorted().len() as u64),
3222 Err(_) => ReportValue::Unavailable,
3223 };
3224 trace::ContextReport {
3225 model: self.model.clone(),
3226 system_prompt_bytes: ReportValue::Count(
3227 self.system_prompt().map(|s| s.len() as u64).unwrap_or(0),
3228 ),
3229 tool_count,
3230 history_messages,
3231 history_bytes,
3232 loaded_skills: ReportValue::Unavailable,
3233 loaded_memories: ReportValue::Unavailable,
3234 cache: self.trace_ctx.cache_snapshots().last_activity(),
3235 trace_enabled: self.trace_ctx.enabled(),
3236 writer_stats: self.telemetry_writer.as_ref().map(|w| w.stats()),
3237 degraded_records: self.trace_ctx.degraded_records(),
3238 }
3239 }
3240
3241 pub fn telemetry_writer(&self) -> Option<crate::runtime::telemetry::TelemetryWriter> {
3243 self.telemetry_writer.clone()
3244 }
3245
3246 pub fn shutdown_observability(
3260 &self,
3261 timeout: std::time::Duration,
3262 ) -> Option<crate::runtime::telemetry::ShutdownOutcome> {
3263 let _ = trace::sweep_expired_captures(&self.capture_dir);
3268 let ephemeral = self
3274 .one_shot_trace_writer
3275 .lock()
3276 .expect("one-shot trace writer lock poisoned")
3277 .take();
3278 let ephemeral_outcome = ephemeral.map(|w| w.shutdown(timeout));
3279 self.telemetry_writer
3280 .as_ref()
3281 .map(|w| w.shutdown(timeout))
3282 .or(ephemeral_outcome)
3283 }
3284
3285 pub async fn shutdown_observability_async(
3298 &self,
3299 timeout: std::time::Duration,
3300 ) -> Option<crate::runtime::telemetry::ShutdownOutcome> {
3301 let capture_dir = self.capture_dir.clone();
3305 let _ =
3306 tokio::task::spawn_blocking(move || trace::sweep_expired_captures(&capture_dir)).await;
3307 let ephemeral = self
3309 .one_shot_trace_writer
3310 .lock()
3311 .expect("one-shot trace writer lock poisoned")
3312 .take();
3313 let ephemeral_outcome = match ephemeral {
3314 Some(writer) => Some(writer.shutdown_async(timeout).await),
3315 None => None,
3316 };
3317 match self.telemetry_writer.clone() {
3318 Some(writer) => Some(writer.shutdown_async(timeout).await),
3319 None => ephemeral_outcome,
3320 }
3321 }
3322
3323 #[cfg(any(test, feature = "testing"))]
3327 pub fn install_observability_for_tests(
3328 &mut self,
3329 writer: crate::runtime::telemetry::TelemetryWriter,
3330 ) {
3331 self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
3332 crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
3333 ));
3334 self.telemetry_writer = Some(writer);
3335 }
3336
3337 pub fn cache_diagnostics(&self) -> bool {
3338 self.cache_diagnostics
3339 }
3340
3341 pub fn set_cache_diagnostics(&mut self, v: bool) {
3342 self.cache_diagnostics = v;
3343 }
3344
3345 pub fn cache_ttl(&self) -> crate::core::config::CacheTtl {
3346 self.cache_ttl
3347 }
3348
3349 pub fn share_ttl_latches(&mut self, parent: &Runtime) {
3353 self.ttl_downgrade_notified = parent.ttl_downgrade_notified.clone();
3354 self.saw_1h_honored = parent.saw_1h_honored.clone();
3355 }
3356
3357 pub fn set_cache_ttl(&mut self, ttl: crate::core::config::CacheTtl) {
3361 self.cache_ttl = ttl;
3362 }
3363
3364 pub fn thinking_level(&self) -> &str {
3365 match self.named_level {
3368 Some(level) => level.as_str(),
3369 None => crate::core::models::thinking_level_for_budget(self.thinking_budget),
3370 }
3371 }
3372
3373 pub async fn refresh_if_needed(&self) -> Result<()> {
3375 self.refresh_if_needed_for_model(&self.model).await
3376 }
3377
3378 async fn refresh_if_needed_for_model(&self, model: &str) -> Result<()> {
3379 if !crate::runtime::auth::model_is_anthropic(model) {
3382 return Ok(());
3383 }
3384 AuthMethods::refresh_if_needed(
3385 Arc::clone(&self.auth),
3386 &self.client,
3387 &self.credential_source,
3388 &self.token_cache,
3389 )
3390 .await
3391 }
3392
3393 pub async fn compact_call(&self, messages: Vec<crate::SharedMessage>) -> Result<String> {
3399 self.remote_summarization_attempts
3402 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3403 let model = self.compaction_model();
3404 self.validate_request_preflight_for(
3405 model,
3406 crate::runtime::openai::catalog::CodexRequestRole::Internal,
3407 )
3408 .await?;
3409 self.refresh_if_needed_for_model(model).await?;
3410
3411 use crate::runtime::compaction::COMPACTION_SYSTEM_PROMPT;
3412
3413 ApiMethods::call_api_simple(
3414 &self.auth,
3415 &self.client,
3416 model,
3417 COMPACTION_SYSTEM_PROMPT,
3418 self.thinking_budget,
3419 self.reasoning_level(),
3420 &messages,
3421 self.api_retries,
3422 &api::ApiOptions {
3428 trace: self.trace_ctx.clone(),
3429 telemetry: self.telemetry_writer.clone(),
3430 ..api::ApiOptions::default()
3431 },
3432 )
3433 .await
3434 }
3435
3436 pub async fn run_single(&self, prompt: &str) -> Result<String> {
3439 if self
3440 .continuation
3441 .lock()
3442 .unwrap_or_else(std::sync::PoisonError::into_inner)
3443 .durability_blocked
3444 {
3445 return Err(RuntimeError::Session(
3446 "context head save is unresolved; reload the session before further inference"
3447 .into(),
3448 ));
3449 }
3450 self.validate_request_preflight().await?;
3451 let anthropic_execution_plan = self.authorized_anthropic_plan().await?;
3452 self.refresh_if_needed().await?;
3454
3455 let mut messages: Vec<crate::SharedMessage> = vec![std::sync::Arc::new(
3456 json!({"role": "user", "content": prompt}),
3457 )];
3458 let system_prompt = self.effective_system_prompt().await;
3459
3460 loop {
3461 let response = ApiMethods::call_api(
3462 &self.auth,
3463 &self.client,
3464 &self.model,
3465 &*self.tools.read().await,
3466 &system_prompt,
3467 self.thinking_budget,
3468 self.reasoning_level(),
3469 &messages,
3470 self.api_retries,
3471 &api::ApiOptions {
3472 memory_backend: None,
3473 use_1m_context: self.context_window_override == Some(1_000_000),
3474 cache_ttl: self.cache_ttl,
3475 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
3476 saw_1h_honored: self.saw_1h_honored.clone(),
3477 credential_source: self.credential_source.clone(),
3478 token_cache: self.token_cache.clone(),
3479 anthropic_base_url: None,
3480 anthropic_execution_plan: anthropic_execution_plan.clone(),
3481 codex_request_role: self.codex_request_role(),
3482 trace: self.effective_trace_context(),
3487 request_correlation: None,
3488 suppress_stream_deltas: true,
3489 telemetry: self.telemetry_writer.clone(),
3490 tool_session_id: Some(self.host_tool_session.clone()),
3497 session_tool_set: None,
3501 request_tools_schema: None,
3502 usage_counters: None,
3503 },
3504 )
3505 .await?;
3506
3507 if let Some(content) = response["content"].as_array() {
3509 let mut response_text = String::new();
3510 let mut tool_uses = Vec::new();
3511
3512 for item in content {
3514 match item["type"].as_str() {
3515 Some("text") => {
3516 if let Some(text) = item["text"].as_str() {
3517 response_text.push_str(text);
3518 }
3519 }
3520 Some("tool_use") => {
3521 tool_uses.push(item.clone());
3522 }
3523 _ => {}
3524 }
3525 }
3526
3527 if tool_uses.is_empty() {
3529 if let Some(orchestration) = &self.orchestration {
3530 if let agent_core::orchestration::CompletionGate::Blocked { workers } =
3531 orchestration.completion_gate()
3532 {
3533 return Err(crate::RuntimeError::Tool(format!(
3534 "completion blocked: {} worker(s) require collection/reconciliation: {} (call subagent_collect with reconciled=true after inspecting each result)",
3535 workers.len(),
3536 workers.join(", ")
3537 )));
3538 }
3539 }
3540 return Ok(response_text);
3541 }
3542
3543 messages.push(std::sync::Arc::new(json!({
3545 "role": "assistant",
3546 "content": content
3547 })));
3548
3549 let mut tool_results = Vec::new();
3551
3552 if tool_uses.len() == 1 {
3553 let tool_use = &tool_uses[0];
3555 if let (Some(tool_name), Some(tool_id)) =
3556 (tool_use["name"].as_str(), tool_use["id"].as_str())
3557 {
3558 let input = &tool_use["input"];
3559 let (result, rich_blocks) = match self.tools.read().await.get(tool_name).cloned() {
3560 Some(tool) => {
3561 let input = self
3562 .tools
3563 .read()
3564 .await
3565 .translate_input_for_api_tool(tool_name, input.clone());
3566 let runtime_name = self
3567 .tools
3568 .read()
3569 .await
3570 .runtime_name_for_api(tool_name)
3571 .to_string();
3572 let ctx = crate::ToolContext {
3573 channels: crate::tools::ToolChannels {
3574 tx_delta: None,
3575 tx_events: None,
3576 },
3577 capabilities: crate::tools::ToolCapabilities {
3578 launch_cancel: None,
3579 memory_backend: Some(self.memory_backend.clone()),
3580 watcher_exit_path: self.watcher_exit_path.clone(),
3581 tool_register_tx: None,
3582 session_manager: Some(self.session_manager.clone()),
3583 subagent_registry: Some(self.subagent_registry.clone()),
3584 delegation_parent: None,
3585 codex_parent_plan: Self::codex_delegation_plan(
3586 &self.model,
3587 self.reasoning_level(),
3588 self.codex_request_role(),
3589 ),
3590 event_queue: Some(self.event_queue.clone()),
3591 secret_prompt: None,
3592 orchestration: self.orchestration.clone(),
3593 tool_activation: None,
3594 mcp_leases: None,
3595 extension_leases: None,
3596 memory_context: self.memory_tool_capability(),
3597 cwd: self.cwd.clone(),
3598 env: self.env.clone(),
3599 env_stripped: self.env_stripped.clone(),
3600 env_warned: self.env_warned.clone(),
3601 },
3602 limits: crate::tools::ToolLimits {
3603 max_tool_output: self.max_tool_output,
3604 max_tool_buffer: 256 * 1024,
3605 bash_timeout: self.bash_timeout,
3606 bash_max_timeout: self.bash_max_timeout,
3607 subagent_timeout: self.subagent_timeout,
3608 },
3609 };
3610 let decision = resolve_before_tool_call_decision(
3611 input.clone(),
3612 emit_before_tool_call(
3613 &self.hook_bus,
3614 tool_name,
3615 Some(&runtime_name),
3616 input.clone(),
3617 self.session_id.as_deref(),
3618 )
3619 .await,
3620 None,
3621 false,
3622 )
3623 .await;
3624 if let BeforeToolCallDecision::Block { reason } = decision {
3625 (format!("Tool call blocked by extension: {}", reason), None)
3626 } else {
3627 let BeforeToolCallDecision::Continue { input } = decision
3628 else {
3629 unreachable!()
3630 };
3631 let input_for_hook = input.clone();
3632 let (output, rich_blocks) = match tool.execute_rich(input, ctx).await {
3633 Ok(output) => validated_single_tool_output(&self.model, output),
3634 Err(e) => (e.to_string(), None),
3635 };
3636 let hooked_output = emit_after_tool_call(
3637 &self.hook_bus,
3638 tool_name,
3639 Some(&runtime_name),
3640 input_for_hook,
3641 output.clone(),
3642 self.max_tool_output,
3643 self.session_id.as_deref(),
3644 )
3645 .await;
3646 let rich_blocks = retain_single_tool_blocks(rich_blocks, &output, &hooked_output);
3647 (hooked_output, rich_blocks)
3648 }
3649 }
3650 None => (format!("Unknown tool: {}", tool_name), None),
3651 };
3652 tool_results.push(json!({
3653 "type": "tool_result",
3654 "tool_use_id": tool_id,
3655 "content": single_tool_result_content(&result, rich_blocks, self.max_tool_output)
3656 }));
3657 }
3658 } else {
3659 let mut join_set = tokio::task::JoinSet::new();
3661
3662 let cfg_max_tool_output = self.max_tool_output;
3664 let cfg_bash_timeout = self.bash_timeout;
3665 let cfg_bash_max_timeout = self.bash_max_timeout;
3666 let cfg_subagent_timeout = self.subagent_timeout;
3667 let session_mgr = self.session_manager.clone();
3668 let cfg_subagent_registry = self.subagent_registry.clone();
3669 let cfg_event_queue = self.event_queue.clone();
3670 let cfg_hook_bus = self.hook_bus.clone();
3671 let cfg_orchestration = self.orchestration.clone();
3672 let codex_parent_plan = Self::codex_delegation_plan(
3673 &self.model,
3674 self.reasoning_level(),
3675 self.codex_request_role(),
3676 );
3677 let cfg_cwd = self.cwd.clone();
3678 let cfg_env = self.env.clone();
3679 let cfg_session_id = self.session_id.clone();
3680
3681 for tool_use in &tool_uses {
3682 if let (Some(tool_name), Some(tool_id)) = (
3683 tool_use["name"].as_str().map(|s| s.to_string()),
3684 tool_use["id"].as_str().map(|s| s.to_string()),
3685 ) {
3686 let input = tool_use["input"].clone();
3687 let tools_snapshot = self.tools.read().await;
3688 let input =
3689 tools_snapshot.translate_input_for_api_tool(&tool_name, input);
3690 let runtime_name =
3691 tools_snapshot.runtime_name_for_api(&tool_name).to_string();
3692 let tool = tools_snapshot.get(&tool_name).cloned();
3693 drop(tools_snapshot);
3694 let exit_path = self.watcher_exit_path.clone();
3695 let session_mgr_inner = session_mgr.clone();
3696 let registry_inner = cfg_subagent_registry.clone();
3697 let event_queue_inner = cfg_event_queue.clone();
3698 let hook_bus_inner = cfg_hook_bus.clone();
3699 let orchestration_inner = cfg_orchestration.clone();
3700 let memory_backend_inner = self.memory_backend.clone();
3701 let memory_context_inner = self.memory_tool_capability();
3702 let codex_parent_plan_inner = codex_parent_plan.clone();
3703 let cwd_inner = cfg_cwd.clone();
3704 let env_inner = cfg_env.clone();
3705 let env_stripped_inner = self.env_stripped.clone();
3706 let env_warned_inner = self.env_warned.clone();
3707 let session_id_inner = cfg_session_id.clone();
3708 let attachment_model = self.model.clone();
3709 let tool_name_for_hook = tool_name.clone();
3710 let runtime_name_for_hook = runtime_name.clone();
3711
3712 join_set.spawn(async move {
3713 let result = match tool {
3714 Some(t) => {
3715 let decision =
3716 crate::runtime::resolve_before_tool_call_decision(
3717 input.clone(),
3718 crate::runtime::emit_before_tool_call(
3719 &hook_bus_inner,
3720 &tool_name_for_hook,
3721 Some(&runtime_name_for_hook),
3722 input.clone(),
3723 session_id_inner.as_deref(),
3724 )
3725 .await,
3726 None,
3727 false,
3728 )
3729 .await;
3730 if let crate::runtime::BeforeToolCallDecision::Block {
3731 reason,
3732 } = decision
3733 {
3734 (format!("Tool call blocked by extension: {}", reason), None)
3735 } else {
3736 let crate::runtime::BeforeToolCallDecision::Continue {
3737 input,
3738 } = decision
3739 else {
3740 unreachable!()
3741 };
3742 let ctx = crate::ToolContext {
3743 channels: crate::tools::ToolChannels {
3744 tx_delta: None,
3745 tx_events: None,
3746 },
3747 capabilities: crate::tools::ToolCapabilities {
3748 launch_cancel: None,
3749 memory_backend: Some(memory_backend_inner),
3750 watcher_exit_path: exit_path,
3751 tool_register_tx: None,
3752 session_manager: Some(session_mgr_inner),
3753 subagent_registry: Some(registry_inner),
3754 delegation_parent: None,
3755 codex_parent_plan: codex_parent_plan_inner,
3756 event_queue: Some(event_queue_inner),
3757 secret_prompt: None,
3758 orchestration: orchestration_inner,
3759 tool_activation: None,
3760 mcp_leases: None,
3761 extension_leases: None,
3762 memory_context: memory_context_inner,
3763 cwd: cwd_inner,
3764 env: env_inner,
3765 env_stripped: env_stripped_inner,
3766 env_warned: env_warned_inner,
3767 },
3768 limits: crate::tools::ToolLimits {
3769 max_tool_output: cfg_max_tool_output,
3770 max_tool_buffer: 256 * 1024,
3771 bash_timeout: cfg_bash_timeout,
3772 bash_max_timeout: cfg_bash_max_timeout,
3773 subagent_timeout: cfg_subagent_timeout,
3774 },
3775 };
3776 let input_for_hook = input.clone();
3777 let (output, rich_blocks) =
3778 match t.execute_rich(input, ctx).await {
3779 Ok(output) => validated_single_tool_output(
3780 &attachment_model,
3781 output,
3782 ),
3783 Err(e) => (e.to_string(), None),
3784 };
3785 let hooked_output =
3786 crate::runtime::emit_after_tool_call(
3787 &hook_bus_inner,
3788 &tool_name_for_hook,
3789 Some(&runtime_name_for_hook),
3790 input_for_hook,
3791 output.clone(),
3792 cfg_max_tool_output,
3793 session_id_inner.as_deref(),
3794 )
3795 .await;
3796 let rich_blocks = retain_single_tool_blocks(
3797 rich_blocks,
3798 &output,
3799 &hooked_output,
3800 );
3801 (hooked_output, rich_blocks)
3802 }
3803 }
3804 None => (format!("Unknown tool: {}", tool_name), None),
3805 };
3806 (tool_id, result)
3807 });
3808 }
3809 }
3810
3811 let mut results_map = std::collections::HashMap::new();
3813 while let Some(res) = join_set.join_next().await {
3814 match res {
3815 Ok((tool_id, result)) => {
3816 results_map.insert(tool_id, result);
3817 }
3818 Err(e) => {
3819 tracing::error!("Parallel tool task panicked: {}", e);
3821 }
3822 }
3823 }
3824
3825 for tool_use in &tool_uses {
3827 if let Some(tool_id) = tool_use["id"].as_str() {
3828 let (result, rich_blocks) = results_map.remove(tool_id).unwrap_or_else(|| {
3829 ("Tool execution failed: task panicked".to_string(), None)
3830 });
3831 tool_results.push(json!({
3832 "type": "tool_result",
3833 "tool_use_id": tool_id,
3834 "content": single_tool_result_content(&result, rich_blocks, self.max_tool_output)
3835 }));
3836 }
3837 }
3838 }
3839
3840 messages.push(std::sync::Arc::new(json!({
3842 "role": "user",
3843 "content": tool_results
3844 })));
3845
3846 } else {
3848 return Err(RuntimeError::Tool("Invalid response format".to_string()));
3849 }
3850 }
3851 }
3852
3853 pub async fn run_stream(
3856 &self,
3857 prompt: String,
3858 cancel: CancellationToken,
3859 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
3860 self.run_stream_with_messages(
3861 vec![std::sync::Arc::new(
3862 json!({"role": "user", "content": prompt}),
3863 )],
3864 cancel,
3865 None,
3866 None,
3867 false,
3868 )
3869 .await
3870 }
3871
3872 pub async fn run_stream_with_messages(
3876 &self,
3877 messages: Vec<crate::SharedMessage>,
3878 cancel: CancellationToken,
3879 steering_rx: Option<mpsc::UnboundedReceiver<String>>,
3880 secret_prompt: Option<crate::tools::SecretPromptHandle>,
3881 auto_approve_confirms: bool,
3882 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
3883 self.run_stream_tracked(
3884 messages,
3885 cancel,
3886 steering_rx,
3887 secret_prompt,
3888 auto_approve_confirms,
3889 TurnCompletion::new(),
3890 )
3891 .await
3892 }
3893
3894 pub async fn run_stream_tracked(
3898 &self,
3899 messages: Vec<crate::SharedMessage>,
3900 cancel: CancellationToken,
3901 steering_rx: Option<mpsc::UnboundedReceiver<String>>,
3902 secret_prompt: Option<crate::tools::SecretPromptHandle>,
3903 auto_approve_confirms: bool,
3904 completion: TurnCompletion,
3905 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
3906 let (tx, internal_rx) = mpsc::unbounded_channel();
3912 let bounded_rx =
3913 crate::runtime::relay::spawn_bounded_stream_relay(internal_rx, cancel.clone());
3914
3915 let turn_correlation_id = agent_core::next_turn_correlation_id();
3918
3919 if self
3920 .continuation
3921 .lock()
3922 .unwrap_or_else(std::sync::PoisonError::into_inner)
3923 .durability_blocked
3924 {
3925 let error = RuntimeError::Session(
3926 "context head save is unresolved; reload the session before further inference"
3927 .into(),
3928 );
3929 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3930 helpers::turn_error_for(&error, &turn_correlation_id),
3931 )));
3932 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3933 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3934 }
3935
3936 if let Err(error) = self.validate_request_preflight().await {
3937 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3938 helpers::turn_error_for(&error, &turn_correlation_id),
3939 )));
3940 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3941 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3942 }
3943
3944 let anthropic_execution_plan = match self.authorized_anthropic_plan().await {
3945 Ok(plan) => plan,
3946 Err(error) => {
3947 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3948 helpers::turn_error_for(&error, &turn_correlation_id),
3949 )));
3950 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3951 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3952 }
3953 };
3954
3955 if let Err(e) = self.refresh_if_needed().await {
3957 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3958 helpers::turn_error_for(&e, &turn_correlation_id),
3959 )));
3960 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3961 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3962 }
3963
3964 let capture_started_at = std::time::SystemTime::now();
3974 let capture_turn_start = terminal_capture_start(&messages);
3975 let capture_lease = self
3978 .memory_context_lock()
3979 .capture_lease_at(capture_started_at);
3980 let mut messages = messages;
3981 self.apply_turn_memory_recall(&mut messages).await;
3982 let messages = messages;
3983
3984 let auth = Arc::clone(&self.auth);
3987 let client = self.client.clone();
3988 let credential_source = self.credential_source.clone();
3989 let token_cache = self.token_cache.clone();
3990 let model = self.model.clone();
3991 let tools = self.tools.clone();
3992 let system_prompt = self.effective_system_prompt().await;
3993 let thinking_budget = self.thinking_budget;
3994 let reasoning_level = self.reasoning_level();
3995 let watcher_exit_path = self.watcher_exit_path.clone();
3996 let max_tool_output = self.max_tool_output;
3997 let bash_timeout = self.bash_timeout;
3998 let bash_max_timeout = self.bash_max_timeout;
3999 let subagent_timeout = self.subagent_timeout;
4000 let api_retries = self.api_retries;
4001 let refusal_retries = self.refusal_retries;
4002 let session_manager = self.session_manager.clone();
4003 let subagent_registry = self.subagent_registry.clone();
4007 let reaper_registry = Arc::clone(&subagent_registry);
4010 let reaper_orchestration = self.orchestration.clone();
4011 let capture_provider = capture_lease
4012 .as_ref()
4013 .and_then(|lease| self.extension_capture_provider(lease));
4014 let capture_state = self.memory_context_state.clone();
4015 let event_queue = self.event_queue.clone();
4016 let options = api::ApiOptions {
4017 memory_backend: None,
4018 use_1m_context: self.context_window_override == Some(1_000_000),
4019 cache_ttl: self.cache_ttl,
4020 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
4021 saw_1h_honored: self.saw_1h_honored.clone(),
4022 credential_source: self.credential_source.clone(),
4023 token_cache: self.token_cache.clone(),
4024 anthropic_base_url: None,
4025 anthropic_execution_plan,
4026 codex_request_role: self.codex_request_role(),
4027 trace: self.effective_trace_context(),
4028 request_correlation: None,
4029 suppress_stream_deltas: false,
4030 telemetry: self.telemetry_writer.clone(),
4031 tool_session_id: Some(self.host_tool_session.clone()),
4034 session_tool_set: None,
4038 request_tools_schema: None,
4039 usage_counters: None,
4040 };
4041
4042 let final_capture_history: Arc<Mutex<Option<Vec<crate::SharedMessage>>>> = Arc::new(Mutex::new(None));
4043 let session = crate::runtime::stream::StreamSession {
4044 memory_backend: self.memory_backend.clone(),
4045 memory_context: self.memory_tool_capability(),
4046 final_capture_history: final_capture_history.clone(),
4047 turn_completion: completion,
4048 context_window: self.context_window(),
4049 continuation: self.continuation.clone(),
4050 auth,
4051 client,
4052 credential_source,
4053 token_cache,
4054 options,
4055 api_retries,
4056 refusal_retries,
4057 model,
4058 tools,
4059 system_prompt,
4060 thinking_budget,
4061 reasoning_level,
4062 tx: tx.clone(),
4063 cancel,
4064 steering_rx,
4065 watcher_exit_path,
4066 max_tool_output,
4067 bash_timeout,
4068 bash_max_timeout,
4069 subagent_timeout,
4070 session_manager,
4071 subagent_registry,
4072 event_queue,
4073 secret_prompt,
4074 hook_bus: self.hook_bus.clone(),
4075 session_id: self.session_id.clone(),
4076 cwd: self.cwd.clone(),
4077 env: self.env.clone(),
4078 env_stripped: self.env_stripped.clone(),
4079 env_warned: self.env_warned.clone(),
4080 auto_approve_confirms,
4081 telemetry_level: self.telemetry_level,
4082 orchestration: self.orchestration.clone(),
4083 delegation_parent: self.delegation_parent.clone(),
4084 turn_correlation_id: turn_correlation_id.clone(),
4085 progressive_tool_disclosure: self.progressive_tool_disclosure,
4086 activation_confirm: self.activation_confirm,
4087 tool_session_id: self.host_tool_session.clone(),
4088 retained_tool_set: std::sync::Arc::clone(&self.retained_tool_set),
4089 mcp_runtime: self.mcp_runtime.clone(),
4090 mcp_session_scope: self.mcp_session_scope.clone(),
4091 extension_runtime: self.extension_runtime.clone(),
4092 extension_session_scope: self.extension_session_scope.clone(),
4093 turn_budget: self.turn_budget.clone(),
4094 };
4095
4096 tokio::spawn(async move {
4097 let completed = match StreamMethods::run_stream_internal(session, messages).await {
4098 Ok(()) => true,
4099 Err(e) => {
4100 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
4101 helpers::turn_error_for(&e, &turn_correlation_id),
4102 )));
4103 false
4104 }
4105 };
4106 dispatch_completed_terminal_capture(
4109 completed,
4110 &final_capture_history,
4111 &capture_state,
4112 capture_lease.zip(capture_provider),
4113 capture_started_at,
4114 capture_turn_start.as_ref(),
4115 );
4116 crate::runtime::subagent::reap_finished(
4120 &reaper_registry,
4121 reaper_orchestration.as_deref(),
4122 );
4123 agent_core::core::memstat::log_turn_memory();
4124 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
4125 });
4126
4127 Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx))
4128 }
4129}
4130
4131impl Clone for Runtime {
4132 fn clone(&self) -> Self {
4133 Self {
4134 client: self.client.clone(),
4135 auth: Arc::clone(&self.auth),
4136 model: self.model.clone(),
4137 tools: self.tools.clone(),
4138 system_prompt: self.system_prompt.clone(),
4139 effective_prompt: self.effective_prompt.clone(),
4140 prompt_generation: self.prompt_generation,
4141 prompt_reload_source: self.prompt_reload_source.clone(),
4142 thinking_budget: self.thinking_budget,
4143 named_level: self.named_level,
4144 explicit_reasoning: self.explicit_reasoning,
4145 codex_request_role: self.codex_request_role,
4146 context_window_override: self.context_window_override,
4147 continuation: Arc::clone(&self.continuation),
4148 compaction_model: self.compaction_model.clone(),
4149 compaction_mode: self.compaction_mode,
4150 compaction_exclusions: self.compaction_exclusions.clone(),
4151 remote_summarization_attempts: Arc::clone(&self.remote_summarization_attempts),
4152 subagent_registry: self.subagent_registry.clone(),
4153 orchestration: self.orchestration.clone(),
4154 event_queue: self.event_queue.clone(),
4155 watcher_exit_path: self.watcher_exit_path.clone(),
4156 max_tool_output: self.max_tool_output,
4157 bash_timeout: self.bash_timeout,
4158 bash_max_timeout: self.bash_max_timeout,
4159 subagent_timeout: self.subagent_timeout,
4160 api_retries: self.api_retries,
4161 refusal_retries: self.refusal_retries,
4162 telemetry_level: self.telemetry_level,
4163 telemetry_writer: self.telemetry_writer.clone(),
4167 trace_ctx: self.trace_ctx.clone(),
4168 trace_controls: Arc::clone(&self.trace_controls),
4169 memory_context_state: std::sync::Arc::clone(&self.memory_context_state),
4175 memory_backend: self.memory_backend.clone(),
4176 memory_backend_config: self.memory_backend_config.clone(),
4177 memory_backend_reconfigure_denied: Arc::clone(&self.memory_backend_reconfigure_denied),
4178 pending_history_import_preview: std::sync::Arc::clone(
4179 &self.pending_history_import_preview,
4180 ),
4181 history_import_plan: std::sync::Arc::clone(&self.history_import_plan),
4182 #[cfg(any(test, feature = "testing"))]
4183 capture_provider_for_test: std::sync::Arc::clone(&self.capture_provider_for_test),
4184 pending_memory_segment: std::sync::Arc::clone(&self.pending_memory_segment),
4187 retained_recall_turn: std::sync::Arc::clone(&self.retained_recall_turn),
4190 one_shot_trace_writer: Arc::clone(&self.one_shot_trace_writer),
4191 capture_dir: self.capture_dir.clone(),
4192 cache_diagnostics: self.cache_diagnostics,
4193 cache_ttl: self.cache_ttl,
4194 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
4198 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
4199 last_msg_id: Arc::new(Mutex::new(None)),
4202 session_manager: self.session_manager.clone(),
4203 hook_bus: self.hook_bus.clone(),
4204 reaper_handle: None, reaper_cancel: None, credential_source: self.credential_source.clone(),
4207 token_cache: self.token_cache.clone(), trusted_worker_models: self.trusted_worker_models.clone(),
4209 progressive_tool_disclosure: self.progressive_tool_disclosure,
4210 activation_confirm: self.activation_confirm,
4211 delegation_parent: self.delegation_parent.clone(),
4212 mcp_runtime: self.mcp_runtime.clone(),
4213 mcp_session_scope: self.mcp_session_scope.clone(),
4216 extension_runtime: self.extension_runtime.clone(),
4217 extension_session_scope: self.extension_session_scope.clone(),
4219 turn_budget: self.turn_budget.clone(),
4220 host_tool_session: self.host_tool_session.clone(),
4225 retained_tool_set: std::sync::Arc::clone(&self.retained_tool_set),
4226 session_id: self.session_id.clone(),
4228 cwd: self.cwd.clone(),
4229 env: self.env.clone(),
4230 env_stripped: self.env_stripped.clone(),
4231 env_warned: self.env_warned.clone(),
4232 }
4233 }
4234}
4235
4236#[cfg(test)]
4237mod rich_output_validation_tests {
4238 use super::*;
4239
4240 const MODEL: &str = "anthropic/claude-sonnet-4-6";
4241
4242 fn rich_output(summary: &str) -> crate::ToolOutput {
4243 crate::ToolOutput::Blocks {
4244 summary: summary.to_string(),
4245 blocks: vec![json!({
4247 "type": "image",
4248 "source": {
4249 "type": "base64",
4250 "media_type": "image/png",
4251 "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/l+QAAAAASUVORK5CYII="
4252 }
4253 })],
4254 }
4255 }
4256
4257 #[test]
4258 fn unchanged_summary_retains_rich_array_without_text_truncation() {
4259 let (summary, blocks) = validated_single_tool_output(MODEL, rich_output("image"));
4260 assert_eq!(summary, "image");
4261 let expected = blocks.clone().expect("supported image");
4262 assert_eq!(expected[0], json!({"type": "text", "text": summary}));
4263 assert_eq!(expected[1]["type"], "image");
4264 let retained = retain_single_tool_blocks(blocks, &summary, &summary);
4265 assert_eq!(
4266 single_tool_result_content(&summary, retained, summary.len()),
4267 Value::Array(expected)
4268 );
4269 }
4270
4271 #[test]
4272 fn rewritten_or_truncated_summary_drops_rich_blocks() {
4273 let (summary, blocks) = validated_single_tool_output(MODEL, rich_output("image summary"));
4274 assert!(blocks.is_some());
4275 for hooked in [
4276 "redacted".to_string(),
4277 String::new(),
4278 helpers::HelperMethods::truncate_tool_result(&summary, 5),
4279 ] {
4280 let retained = retain_single_tool_blocks(blocks.clone(), &summary, &hooked);
4281 assert!(retained.is_none());
4282 assert_eq!(
4283 single_tool_result_content(&hooked, retained, 5),
4284 Value::String(helpers::HelperMethods::truncate_tool_result(&hooked, 5))
4285 );
4286 }
4287 }
4288
4289 #[test]
4290 fn unsupported_or_malformed_media_becomes_explicit_text() {
4291 let mut malformed = rich_output("private summary");
4292 if let crate::ToolOutput::Blocks { blocks, .. } = &mut malformed {
4293 blocks[0]["source"]["data"] = json!("private invalid payload");
4294 }
4295 for (model, output) in [
4296 (
4297 "google-gemini/unsupported-rich-test",
4298 rich_output("private summary"),
4299 ),
4300 (MODEL, malformed),
4301 ] {
4302 let (summary, blocks) = validated_single_tool_output(model, output);
4303 assert!(summary.starts_with("Attachment not sent: "), "{summary}");
4304 assert!(!summary.contains("private"));
4305 assert!(blocks.is_none());
4306 assert_eq!(
4307 single_tool_result_content(&summary, blocks, 1024),
4308 Value::String(summary)
4309 );
4310 }
4311 }
4312
4313 #[test]
4314 fn plain_text_and_errors_keep_legacy_truncation() {
4315 for text in ["plain output", "tool error", "Unknown tool: missing"] {
4316 let (summary, blocks) = validated_single_tool_output(
4317 "google-gemini/unsupported-rich-test",
4318 crate::ToolOutput::Text(text.to_string()),
4319 );
4320 assert_eq!(summary, text);
4321 assert!(blocks.is_none());
4322 assert_eq!(
4323 single_tool_result_content(&summary, blocks, 5),
4324 Value::String(helpers::HelperMethods::truncate_tool_result(text, 5))
4325 );
4326 }
4327 }
4328}
4329
4330#[cfg(test)]
4331mod tests {
4332 use super::*;
4333
4334 #[test]
4339 fn host_tool_session_shared_by_clones_fresh_per_runtime() {
4340 let rt = Runtime::new_headless();
4341 let clone = rt.clone();
4342 assert_eq!(
4343 rt.host_tool_session_id(),
4344 clone.host_tool_session_id(),
4345 "clones share the live registry and therefore the host session"
4346 );
4347
4348 let other = Runtime::new_headless();
4349 assert_ne!(
4350 rt.host_tool_session_id(),
4351 other.host_tool_session_id(),
4352 "independently constructed runtimes must never share a session"
4353 );
4354 }
4355
4356 #[test]
4361 fn telemetry_level_gates_shared_observability_sink() {
4362 let mut rt = Runtime::new_headless();
4363 assert!(!rt.trace_context().enabled(), "off by default");
4364 assert!(rt.telemetry_writer().is_none());
4365
4366 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Basic);
4367 assert!(rt.trace_context().enabled(), "basic enables the trace sink");
4368 assert!(rt.telemetry_writer().is_some());
4369
4370 let ctx_before = format!("{:?}", rt.trace_context());
4372 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Full);
4373 assert_eq!(format!("{:?}", rt.trace_context()), ctx_before);
4374
4375 let clone = rt.clone();
4377 assert!(clone.trace_context().enabled());
4378 assert_eq!(format!("{:?}", clone.trace_context()), ctx_before);
4379 assert!(clone.telemetry_writer().is_some());
4380
4381 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Off);
4382 assert!(!rt.trace_context().enabled(), "off disables both");
4383 assert!(rt.telemetry_writer().is_none());
4384 assert!(rt
4385 .shutdown_observability(std::time::Duration::ZERO)
4386 .is_none());
4387 }
4388
4389 #[tokio::test]
4395 async fn shutdown_observability_async_epilogue_contract() {
4396 let rt = Runtime::new_headless();
4398 let budget = crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT;
4399 assert!(rt.shutdown_observability_async(budget).await.is_none());
4400 assert!(rt.shutdown_observability_async(budget).await.is_none());
4401
4402 let tmp = tempfile::tempdir().unwrap();
4404 let telemetry_path = tmp.path().join("synaps/api-log.jsonl");
4405 let mut rt = Runtime::new_headless();
4406 let writer = crate::runtime::telemetry::TelemetryWriter::new(
4407 crate::runtime::telemetry::WriterOptions {
4408 telemetry_path: Some(telemetry_path.clone()),
4409 trace_path: Some(tmp.path().join("synaps/request-trace.jsonl")),
4410 ..Default::default()
4411 },
4412 );
4413 rt.install_observability_for_tests(writer.clone());
4414 writer.enqueue_telemetry(crate::runtime::telemetry::TelemetryRecord {
4415 ts: 7,
4416 model: "claude-sonnet-4-6".to_string(),
4417 ..Default::default()
4418 });
4419 let outcome = rt
4420 .shutdown_observability_async(budget)
4421 .await
4422 .expect("writer installed");
4423 assert!(outcome.is_flushed());
4424 assert_eq!(outcome.stats().written, 1);
4425 assert_eq!(
4426 std::fs::read_to_string(&telemetry_path)
4427 .unwrap()
4428 .lines()
4429 .count(),
4430 1,
4431 "queued final record must be on disk after the epilogue flush"
4432 );
4433 assert!(rt
4435 .shutdown_observability_async(budget)
4436 .await
4437 .expect("writer still installed")
4438 .is_flushed());
4439
4440 let mut rt = Runtime::new_headless();
4442 let slow = crate::runtime::telemetry::TelemetryWriter::new(
4443 crate::runtime::telemetry::WriterOptions {
4444 telemetry_path: Some(tmp.path().join("slow/api-log.jsonl")),
4445 trace_path: Some(tmp.path().join("slow/request-trace.jsonl")),
4446 write_delay: Some(std::time::Duration::from_millis(300)),
4447 ..Default::default()
4448 },
4449 );
4450 rt.install_observability_for_tests(slow.clone());
4451 for _ in 0..10 {
4452 slow.enqueue_telemetry(crate::runtime::telemetry::TelemetryRecord::default());
4453 }
4454 let start = std::time::Instant::now();
4455 let outcome = rt
4456 .shutdown_observability_async(std::time::Duration::from_millis(200))
4457 .await
4458 .expect("writer installed");
4459 assert!(!outcome.is_flushed(), "10×300ms cannot drain in 200ms");
4460 assert!(
4463 start.elapsed() < std::time::Duration::from_millis(1500),
4464 "bounded epilogue must not block exit: {:?}",
4465 start.elapsed()
4466 );
4467 }
4468
4469 #[tokio::test]
4474 async fn one_shot_ephemeral_writer_is_retained_and_drained_at_shutdown() {
4475 let rt = Runtime::new_headless();
4476 assert!(!rt.trace_context().enabled(), "telemetry off");
4477 rt.trace_arm_next(false);
4478 let armed = rt.effective_trace_context();
4479 assert!(armed.enabled(), "armed ephemeral context traces");
4480 let budget = crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT;
4483 let outcome = rt
4484 .shutdown_observability_async(budget)
4485 .await
4486 .expect("retained one-shot writer must be drained by the epilogue");
4487 assert!(outcome.is_flushed());
4488 assert!(rt.shutdown_observability_async(budget).await.is_none());
4490 }
4491
4492 #[tokio::test]
4498 #[serial_test::serial(synaps_base_dir)]
4499 async fn startup_and_shutdown_paths_sweep_expired_captures() {
4500 struct BaseDirGuard(Option<String>);
4501 impl Drop for BaseDirGuard {
4502 fn drop(&mut self) {
4503 match self.0.take() {
4504 Some(old) => std::env::set_var("SYNAPS_BASE_DIR", old),
4505 None => std::env::remove_var("SYNAPS_BASE_DIR"),
4506 }
4507 }
4508 }
4509 let tmp = tempfile::tempdir().unwrap();
4510 let guard = BaseDirGuard(std::env::var("SYNAPS_BASE_DIR").ok());
4511 std::env::set_var("SYNAPS_BASE_DIR", tmp.path());
4512
4513 let cap_dir = trace::default_capture_dir();
4514 assert!(
4515 cap_dir.starts_with(tmp.path()),
4516 "capture dir must be private to the test"
4517 );
4518 let stale_id = trace::TraceId::new("req-stale").unwrap();
4519 let plant_stale = || {
4520 agent_core::core::private_fs::ensure_private_dir(&cap_dir).unwrap();
4521 let stale = trace::controls::ContentCaptureBundle {
4522 schema: trace::CONTENT_CAPTURE_SCHEMA.to_string(),
4523 request_id: stale_id.clone(),
4524 created_unix_ms: 1_000,
4525 expires_unix_ms: 2_000, redacted: true,
4527 over_budget: false,
4528 body: Some(serde_json::json!({"old": true})),
4529 };
4530 let path = trace::controls::capture_path(&cap_dir, &stale_id);
4531 std::fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap();
4532 path
4533 };
4534
4535 let stale_path = plant_stale();
4537 let rt = Runtime::new().await.expect("runtime constructs");
4538 assert!(
4539 !stale_path.exists(),
4540 "Runtime startup must physically remove expired capture bundles"
4541 );
4542
4543 let stale_path = plant_stale();
4545 let _ = rt.shutdown_observability(std::time::Duration::ZERO);
4546 assert!(
4547 !stale_path.exists(),
4548 "sync shutdown epilogue must remove expired capture bundles"
4549 );
4550
4551 let stale_path = plant_stale();
4553 let _ = rt
4554 .shutdown_observability_async(std::time::Duration::from_millis(100))
4555 .await;
4556 assert!(
4557 !stale_path.exists(),
4558 "async shutdown epilogue must remove expired capture bundles"
4559 );
4560 drop(guard);
4561 }
4562
4563 #[tokio::test]
4568 #[serial_test::serial(synaps_base_dir)]
4569 async fn capture_root_binds_at_construction_and_survives_env_churn() {
4570 let base_a = tempfile::tempdir().unwrap();
4571 let base_b = tempfile::tempdir().unwrap();
4572 let old = std::env::var("SYNAPS_BASE_DIR").ok();
4573
4574 std::env::set_var("SYNAPS_BASE_DIR", base_a.path());
4575 let rt_a = Runtime::new().await.expect("runtime A");
4576 std::env::set_var("SYNAPS_BASE_DIR", base_b.path());
4577 let rt_b = Runtime::new().await.expect("runtime B");
4578
4579 let cap_a = base_a.path().join("trace").join("capture");
4580 let cap_b = base_b.path().join("trace").join("capture");
4581 let plant = |dir: &std::path::Path, name: &str| {
4582 agent_core::core::private_fs::ensure_private_dir(dir).unwrap();
4583 let id = trace::TraceId::new(name).unwrap();
4584 let stale = trace::controls::ContentCaptureBundle {
4585 schema: trace::CONTENT_CAPTURE_SCHEMA.to_string(),
4586 request_id: id.clone(),
4587 created_unix_ms: 1_000,
4588 expires_unix_ms: 2_000,
4589 redacted: true,
4590 over_budget: false,
4591 body: Some(serde_json::json!({"old": true})),
4592 };
4593 let path = trace::controls::capture_path(dir, &id);
4594 std::fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap();
4595 path
4596 };
4597
4598 let decoy = tempfile::tempdir().unwrap();
4600 std::env::set_var("SYNAPS_BASE_DIR", decoy.path());
4601
4602 let stale_a = plant(&cap_a, "req-stale-a");
4603 let stale_b = plant(&cap_b, "req-stale-b");
4604 let _ = rt_a.shutdown_observability(std::time::Duration::ZERO);
4605 assert!(
4606 !stale_a.exists(),
4607 "runtime A must sweep the root bound at ITS construction"
4608 );
4609 assert!(
4610 stale_b.exists(),
4611 "runtime A must never sweep another runtime's root"
4612 );
4613 let _ = rt_b
4614 .shutdown_observability_async(std::time::Duration::from_millis(100))
4615 .await;
4616 assert!(
4617 !stale_b.exists(),
4618 "runtime B must sweep the root bound at ITS construction"
4619 );
4620
4621 match old {
4622 Some(v) => std::env::set_var("SYNAPS_BASE_DIR", v),
4623 None => std::env::remove_var("SYNAPS_BASE_DIR"),
4624 }
4625 }
4626
4627 #[test]
4628 fn reload_rejects_policy_change_without_partial_apply() {
4629 let dir = tempfile::tempdir().unwrap();
4630 let path = dir.path().join("prompt.yaml");
4631 let manifest = |total: &str, content: &str| {
4632 format!(
4633 "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"
4634 )
4635 };
4636 std::fs::write(&path, manifest("1", "before")).unwrap();
4637 let parsed =
4638 agent_core::prompt::PromptManifest::parse(&std::fs::read_to_string(&path).unwrap())
4639 .unwrap();
4640 let model =
4641 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-sonnet-4-6").unwrap();
4642 let context = agent_core::prompt::SelectionContext::new(model.clone(), None).unwrap();
4643 let stack = agent_core::prompt::compile_prompt_stack(
4644 &parsed,
4645 &parsed.registry(path.parent()).unwrap(),
4646 &context,
4647 None,
4648 )
4649 .unwrap();
4650 let catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
4651 &model,
4652 parsed.delegation_catalog_candidates(),
4653 )
4654 .unwrap();
4655 let digest = parsed
4656 .delegation_policy(model, &catalog)
4657 .unwrap()
4658 .map(|p| p.digest());
4659 let mut runtime = Runtime::new_headless();
4660 runtime.apply_prompt_stack(stack).unwrap();
4661 runtime.retain_prompt_reload_source(path.clone(), context, None, digest.clone());
4662 let generation = runtime.prompt_generation();
4663 let composed = runtime.effective_prompt().unwrap().composed().to_owned();
4664
4665 std::fs::write(&path, manifest("2", "after")).unwrap();
4666 let error = runtime.reload_prompt().unwrap_err().to_string();
4667 assert!(error.contains("cannot safely change delegation policy"));
4668 assert_eq!(runtime.prompt_generation(), generation);
4669 assert_eq!(runtime.effective_prompt().unwrap().composed(), composed);
4670 assert_eq!(
4671 runtime
4672 .prompt_reload_source
4673 .as_ref()
4674 .unwrap()
4675 .delegation_policy_digest,
4676 digest
4677 );
4678 }
4679
4680 #[test]
4681 fn worker_install_does_not_replay_process_global_favorite_grants() {
4682 let foreground =
4683 crate::orchestration::canonical_foreground_identity("anthropic/claude-fable-5")
4684 .unwrap();
4685 let session = Arc::new(
4686 crate::orchestration::OrchestrationRuntime::baseline(foreground, 4, 8).unwrap(),
4687 );
4688 let favorite = "openai-codex/gpt-5.6-sol";
4689 assert!(
4690 session.preflight(favorite).is_err(),
4691 "precondition: the parent session did not grant this identity"
4692 );
4693
4694 let mut worker = Runtime::new_headless();
4695 worker.apply_config(&crate::config::SynapsConfig {
4696 favorite_models: vec![favorite.to_owned()],
4697 ..Default::default()
4698 });
4699 worker.install_worker_orchestration(Arc::clone(&session));
4700
4701 assert!(
4702 session.preflight(favorite).is_err(),
4703 "constructing a child must not mint/replay a favorite-model grant"
4704 );
4705 assert!(!session.effective_choices().contains(&favorite.to_owned()));
4706 }
4707
4708 #[test]
4709 fn host_install_may_apply_explicit_operator_favorites() {
4710 let mut runtime = Runtime::new_headless();
4711 let config = crate::config::SynapsConfig {
4712 favorite_models: vec![
4713 "openai-codex/gpt-5.6-luna".to_owned(),
4714 "anthropic/claude-opus-4-6".to_owned(),
4715 "claude/claude-fable-5".to_owned(),
4717 "not-qualified".to_owned(),
4719 ],
4720 ..Default::default()
4721 };
4722 runtime.apply_config(&config);
4723 let foreground =
4724 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
4725 runtime.install_orchestration(std::sync::Arc::new(
4726 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap(),
4727 ));
4728
4729 let orchestration = runtime.orchestration().unwrap();
4730 for trusted in [
4731 "openai-codex/gpt-5.6-sol",
4732 "openai-codex/gpt-5.6-luna",
4733 "anthropic/claude-opus-4-6",
4734 "anthropic/claude-fable-5",
4735 ] {
4736 assert!(
4737 orchestration
4738 .effective_choices()
4739 .contains(&trusted.to_owned()),
4740 "missing configured worker choice {trusted}: {:?}",
4741 orchestration.effective_choices()
4742 );
4743 }
4744 let authorized = orchestration
4745 .resolve_and_authorize("sa_cross_provider", Some("anthropic/claude-opus-4-6"))
4746 .expect("an explicitly favorited cross-provider model must be authorized");
4747 assert_eq!(authorized.model.as_str(), "anthropic/claude-opus-4-6");
4748 assert!(!orchestration
4749 .effective_choices()
4750 .contains(&"not-qualified".to_owned()));
4751 }
4752
4753 #[test]
4754 fn configured_worker_choices_survive_manifestless_foreground_changes() {
4755 let mut runtime = Runtime::new_headless();
4756 runtime.apply_config(&crate::config::SynapsConfig {
4757 favorite_models: vec!["anthropic/claude-opus-4-6".to_owned()],
4758 ..Default::default()
4759 });
4760 let foreground =
4761 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
4762 runtime.install_orchestration(std::sync::Arc::new(
4763 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap(),
4764 ));
4765
4766 runtime
4767 .try_set_model("xai-auth/grok-4.5-latest".to_owned())
4768 .unwrap();
4769
4770 let orchestration = runtime.orchestration().unwrap();
4771 assert_eq!(orchestration.foreground_model(), "xai-auth/grok-4.5-latest");
4772 assert!(orchestration
4773 .effective_choices()
4774 .contains(&"anthropic/claude-opus-4-6".to_owned()));
4775 orchestration
4776 .resolve_and_authorize("sa_after_switch", Some("anthropic/claude-opus-4-6"))
4777 .expect("configured worker trust must survive policy replacement");
4778 }
4779
4780 #[test]
4781 fn set_model_replaces_manifestless_orchestration_foreground_snapshot() {
4782 let mut runtime = Runtime::new_headless();
4783 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4784 runtime.install_orchestration(std::sync::Arc::new(
4785 crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
4786 ));
4787
4788 runtime.set_model("openrouter/deepseek/deepseek-v4-pro".to_owned());
4789
4790 let orchestration = runtime.orchestration().unwrap();
4791 assert_eq!(
4792 orchestration.foreground_model(),
4793 "openrouter/deepseek/deepseek-v4-pro"
4794 );
4795 assert!(orchestration
4796 .effective_choices()
4797 .contains(&"openrouter/moonshotai/kimi-k2.7-code".to_owned()));
4798 assert!(orchestration
4799 .resolve_and_authorize("sa_kimi", Some("openrouter/moonshotai/kimi-k2.7-code"))
4800 .is_ok());
4801 }
4802
4803 #[test]
4804 fn set_model_refuses_to_orphan_unreconciled_policy_workers() {
4805 let mut runtime = Runtime::new_headless();
4806 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4807 let orchestration = std::sync::Arc::new(
4808 crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
4809 );
4810 orchestration
4811 .resolve_and_authorize("sa_active", None)
4812 .unwrap();
4813 runtime.install_orchestration(orchestration.clone());
4814 runtime.model = "openrouter/z-ai/glm-5.2".to_owned();
4815
4816 let error = runtime
4817 .try_set_model("openrouter/deepseek/deepseek-v4-pro".to_owned())
4818 .unwrap_err();
4819
4820 assert!(error.contains("collection/reconciliation"));
4821 assert_eq!(runtime.model(), "openrouter/z-ai/glm-5.2");
4822 assert!(std::sync::Arc::ptr_eq(
4823 runtime.orchestration().unwrap(),
4824 &orchestration
4825 ));
4826 assert!(orchestration.is_unreconciled("sa_active"));
4827 }
4828
4829 #[test]
4830 fn set_model_keeps_active_typed_manifest_orchestration_unchanged() {
4831 let dir = tempfile::tempdir().unwrap();
4832 let manifest_path = dir.path().join("prompt.yaml");
4833 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4834 let mut runtime = Runtime::new_headless();
4835 let orchestration = std::sync::Arc::new(
4836 crate::orchestration::OrchestrationRuntime::baseline(glm.clone(), 3, 8).unwrap(),
4837 );
4838 runtime.install_orchestration(orchestration.clone());
4839 runtime.retain_prompt_reload_source(
4840 manifest_path,
4841 agent_core::prompt::SelectionContext::new(glm, None).unwrap(),
4842 None,
4843 None,
4844 );
4845
4846 runtime.set_model("openrouter/deepseek/deepseek-v4-pro".to_owned());
4847
4848 assert!(std::sync::Arc::ptr_eq(
4849 runtime.orchestration().unwrap(),
4850 &orchestration
4851 ));
4852 assert_eq!(
4853 runtime.orchestration().unwrap().foreground_model(),
4854 "openrouter/z-ai/glm-5.2"
4855 );
4856 }
4857
4858 #[test]
4859 fn set_model_preserves_qualified_provider_ids_and_bare_claude() {
4860 let cases = [
4861 (
4862 "github-copilot/claude-opus-4.8",
4863 "github-copilot/claude-opus-4.8",
4864 ),
4865 ("anthropic/claude-sonnet-4-6", "anthropic/claude-sonnet-4-6"),
4866 ("google/gemini-2.5-pro", "google/gemini-2.5-pro"),
4867 ("openai/gpt-5", "openai/gpt-5"),
4868 ("claude-opus-4-8", "claude-opus-4-8"),
4869 ];
4870
4871 for (input, expected) in cases {
4872 let mut runtime = Runtime::new_headless();
4873 runtime.set_model(input.to_owned());
4874 assert_eq!(runtime.model(), expected, "input: {input}");
4875 }
4876 }
4877
4878 #[test]
4879 fn set_model_strips_only_legacy_health_status_decoration() {
4880 let cases = [
4881 ("✅ 339ms groq/llama-3.3-70b", "groq/llama-3.3-70b"),
4882 (
4883 "✅ 339ms github-copilot/claude-opus-4.8",
4884 "github-copilot/claude-opus-4.8",
4885 ),
4886 (
4887 "⚠️ 1200ms anthropic/claude-sonnet-4-6",
4888 "anthropic/claude-sonnet-4-6",
4889 ),
4890 ];
4891
4892 for (input, expected) in cases {
4893 let mut runtime = Runtime::new_headless();
4894 runtime.set_model(input.to_owned());
4895 assert_eq!(runtime.model(), expected, "input: {input}");
4896 }
4897 }
4898
4899 #[tokio::test]
4900 async fn confirm_without_prompt_fails_closed() {
4901 let result = resolve_before_tool_call_result(
4902 crate::extensions::hooks::events::HookResult::Confirm {
4903 message: "Run deploy?".into(),
4904 },
4905 None,
4906 false,
4907 )
4908 .await;
4909
4910 assert!(matches!(
4911 result,
4912 crate::extensions::hooks::events::HookResult::Block { reason }
4913 if reason.contains("requires confirmation") && reason.contains("Run deploy?")
4914 ));
4915 }
4916
4917 #[tokio::test]
4918 async fn modify_result_replaces_tool_input() {
4919 let result = resolve_before_tool_call_decision(
4920 serde_json::json!({"command":"rm -rf /"}),
4921 crate::extensions::hooks::events::HookResult::Modify {
4922 input: serde_json::json!({"command":"echo safe"}),
4923 },
4924 None,
4925 false,
4926 )
4927 .await;
4928
4929 match result {
4930 BeforeToolCallDecision::Continue { input } => {
4931 assert_eq!(input, serde_json::json!({"command":"echo safe"}));
4932 }
4933 BeforeToolCallDecision::Block { reason } => panic!("unexpected block: {reason}"),
4934 }
4935 }
4936
4937 #[tokio::test]
4938 async fn confirm_prompt_yes_continues() {
4939 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4940 let handle = crate::tools::SecretPromptHandle::new(tx);
4941
4942 let task = tokio::spawn(async move {
4943 let request = rx.recv().await.expect("confirm prompt request");
4944 assert_eq!(request.title, "Confirm tool call");
4945 assert!(request.prompt.contains("Run deploy?"));
4946 let _ = request.response_tx.send(Some("yes".to_string()));
4947 });
4948
4949 let result = resolve_before_tool_call_result(
4950 crate::extensions::hooks::events::HookResult::Confirm {
4951 message: "Run deploy?".into(),
4952 },
4953 Some(&handle),
4954 false,
4955 )
4956 .await;
4957
4958 task.await.unwrap();
4959 assert!(matches!(
4960 result,
4961 crate::extensions::hooks::events::HookResult::Continue
4962 ));
4963 }
4964
4965 #[tokio::test]
4966 async fn confirm_prompt_non_yes_blocks() {
4967 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4968 let handle = crate::tools::SecretPromptHandle::new(tx);
4969
4970 let task = tokio::spawn(async move {
4971 let request = rx.recv().await.expect("confirm prompt request");
4972 let _ = request.response_tx.send(Some("no".to_string()));
4973 });
4974
4975 let result = resolve_before_tool_call_result(
4976 crate::extensions::hooks::events::HookResult::Confirm {
4977 message: "Run deploy?".into(),
4978 },
4979 Some(&handle),
4980 false,
4981 )
4982 .await;
4983
4984 task.await.unwrap();
4985 assert!(matches!(
4986 result,
4987 crate::extensions::hooks::events::HookResult::Block { reason }
4988 if reason.contains("confirmation denied")
4989 ));
4990 }
4991
4992 #[test]
4993 fn test_max_tokens_for_model() {
4994 assert_eq!(
4996 HelperMethods::max_tokens_for_model("claude-opus-4-6"),
4997 128000
4998 );
4999 assert_eq!(
5000 HelperMethods::max_tokens_for_model("opus-something"),
5001 128000
5002 );
5003
5004 assert_eq!(
5006 HelperMethods::max_tokens_for_model("claude-sonnet-4-20250514"),
5007 64000
5008 );
5009 assert_eq!(HelperMethods::max_tokens_for_model("haiku"), 64000);
5010 assert_eq!(HelperMethods::max_tokens_for_model("claude-3-haiku"), 64000);
5011 assert_eq!(
5012 HelperMethods::max_tokens_for_model("some-other-model"),
5013 64000
5014 );
5015
5016 assert_eq!(HelperMethods::max_tokens_for_model(""), 64000);
5018 assert_eq!(HelperMethods::max_tokens_for_model("OPUS"), 64000); assert_eq!(
5020 HelperMethods::max_tokens_for_model("model-opus-end"),
5021 128000
5022 ); }
5024
5025 #[test]
5026 fn test_truncate_tool_result() {
5027 let default_max = 30000;
5028
5029 let short = "This is a short string.";
5031 assert_eq!(
5032 HelperMethods::truncate_tool_result(short, default_max),
5033 short
5034 );
5035
5036 let exact = "x".repeat(30000);
5038 assert_eq!(
5039 HelperMethods::truncate_tool_result(&exact, default_max),
5040 exact
5041 );
5042
5043 let too_long = "x".repeat(30001);
5045 let truncated = HelperMethods::truncate_tool_result(&too_long, default_max);
5046
5047 assert!(truncated.starts_with(&"x".repeat(30000)));
5049
5050 assert!(truncated.contains("[truncated — 30001 total bytes, showing first 30000]"));
5052
5053 assert!(truncated.len() > 30000);
5055
5056 let very_long = "a".repeat(50000);
5058 let truncated_very_long = HelperMethods::truncate_tool_result(&very_long, default_max);
5059 assert!(
5060 truncated_very_long.contains("[truncated — 50000 total bytes, showing first 30000]")
5061 );
5062 assert!(truncated_very_long.starts_with(&"a".repeat(30000)));
5063
5064 let custom_truncated = HelperMethods::truncate_tool_result(&very_long, 100);
5066 assert!(custom_truncated.starts_with(&"a".repeat(100)));
5067 assert!(custom_truncated.contains("[truncated — 50000 total bytes, showing first 100]"));
5068 }
5069
5070 #[test]
5074 fn truncate_tool_result_enforces_byte_budget() {
5075 let multibyte = "é".repeat(100); let out = HelperMethods::truncate_tool_result(&multibyte, 50);
5077 let body = out.split("\n\n[truncated").next().unwrap();
5078 assert!(
5079 body.len() <= 50,
5080 "retained body must fit the byte budget (got {} bytes)",
5081 body.len()
5082 );
5083 assert!(std::str::from_utf8(body.as_bytes()).is_ok());
5084 assert!(
5085 out.contains("total bytes"),
5086 "marker must report byte counts, got: {out}"
5087 );
5088 }
5089
5090 #[test]
5091 fn test_thinking_level_ranges() {
5092 use crate::core::models::thinking_level_for_budget;
5093
5094 assert_eq!(thinking_level_for_budget(0), "adaptive");
5096
5097 assert_eq!(thinking_level_for_budget(1), "low");
5099 assert_eq!(thinking_level_for_budget(1024), "low");
5100 assert_eq!(thinking_level_for_budget(2048), "low");
5101
5102 assert_eq!(thinking_level_for_budget(2049), "medium");
5104 assert_eq!(thinking_level_for_budget(3000), "medium");
5105 assert_eq!(thinking_level_for_budget(4096), "medium");
5106
5107 assert_eq!(thinking_level_for_budget(4097), "high");
5109 assert_eq!(thinking_level_for_budget(8192), "high");
5110 assert_eq!(thinking_level_for_budget(16384), "high");
5111
5112 assert_eq!(thinking_level_for_budget(16385), "xhigh");
5114 assert_eq!(thinking_level_for_budget(32768), "xhigh");
5115 assert_eq!(thinking_level_for_budget(100000), "xhigh");
5116 }
5117}
5118
5119#[cfg(test)]
5120mod effective_prompt_tests {
5121 use super::*;
5122
5123 fn headless(model: &str, base: &str) -> Runtime {
5124 let mut runtime = Runtime::new_headless();
5125 runtime.set_model(model.to_string());
5126 runtime.set_system_prompt(base.to_string());
5127 runtime
5128 }
5129
5130 #[tokio::test]
5131 async fn anthropic_foreground_ultracode_composes_standing_doctrine_once() {
5132 let mut runtime = headless("anthropic/claude-fable-5", "BASE.");
5133 runtime.named_level = Some(agent_core::reasoning::ReasoningLevel::UltraCode);
5134 let prompt = runtime.effective_system_prompt().await.expect("doctrine");
5135 assert_eq!(prompt.matches("<anthropic-ultracode-workflow>").count(), 1);
5136 assert!(prompt.contains("subagent_start"));
5137 assert!(prompt.contains("subagent_resume"));
5138 }
5139
5140 #[tokio::test]
5141 async fn codex_foreground_with_subagent_tools_composes_doctrine() {
5142 let runtime = headless("openai-codex/gpt-5.6-sol", "BASE.");
5143 let prompt = runtime
5144 .effective_system_prompt()
5145 .await
5146 .expect("prompt must compose");
5147 assert!(prompt.starts_with("BASE."), "base must lead");
5148 assert!(prompt.contains("## Subagent supervision"));
5149 assert!(prompt.contains("NEVER end your turn"));
5150 }
5151
5152 #[tokio::test]
5153 async fn worker_runtime_without_subagent_tools_stays_clean() {
5154 let mut runtime = headless("openai-codex/gpt-5.6-sol", "WORKER.");
5155 runtime.set_tools(ToolRegistry::without_subagent());
5156 assert_eq!(
5157 runtime.effective_system_prompt().await.as_deref(),
5158 Some("WORKER."),
5159 "workers have no subagent tools; doctrine would be unactionable noise"
5160 );
5161 }
5162
5163 #[tokio::test]
5164 async fn non_codex_foreground_stays_clean() {
5165 let runtime = headless("xai-auth/grok-4.5-latest", "BASE.");
5166 assert_eq!(
5167 runtime.effective_system_prompt().await.as_deref(),
5168 Some("BASE.")
5169 );
5170 }
5171
5172 #[tokio::test]
5173 async fn manifest_prompt_is_returned_verbatim() {
5174 let mut runtime = headless("openai-codex/gpt-5.6-sol", "IGNORED.");
5175 let module = agent_core::prompt::PromptModule::new(
5176 agent_core::prompt::PromptModuleId::parse("kernel.test").unwrap(),
5177 "1.0.0",
5178 agent_core::prompt::PromptModuleSource::User,
5179 0,
5180 agent_core::prompt::PromptSelectors::default(),
5181 agent_core::prompt::ModuleMutability::MutableGuidance,
5182 "KERNEL.",
5183 )
5184 .unwrap();
5185 let context = agent_core::prompt::SelectionContext::new(
5186 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap(),
5187 None,
5188 )
5189 .unwrap();
5190 let stack = agent_core::prompt::PromptStack::new(vec![module], context).unwrap();
5191 runtime.apply_prompt_stack(stack).unwrap();
5192 assert_eq!(
5193 runtime.effective_system_prompt().await.as_deref(),
5194 Some("KERNEL."),
5195 "manifest authors own the full stack; no builtin injection on top"
5196 );
5197 }
5198
5199 #[tokio::test]
5200 async fn unresolvable_model_identity_fails_closed_to_base() {
5201 let runtime = headless("definitely-not-a-model", "BASE.");
5202 assert_eq!(
5203 runtime.effective_system_prompt().await.as_deref(),
5204 Some("BASE.")
5205 );
5206 }
5207}
5208
5209#[cfg(test)]
5210mod set_reasoning_level_checked_tests {
5211 use super::*;
5212 use agent_core::reasoning::ReasoningLevel;
5213
5214 fn codex_runtime(model_id: &str) -> Runtime {
5215 let mut rt = Runtime::new_headless();
5216 rt.set_model(format!("openai-codex/{model_id}"));
5217 rt.set_reasoning_level(ReasoningLevel::Low);
5218 rt
5219 }
5220
5221 fn provisioned_fable_runtime() -> Runtime {
5222 let mut rt = Runtime::new_headless();
5223 rt.set_model("anthropic/claude-fable-5".into());
5224 rt.set_reasoning_level(ReasoningLevel::Low);
5225 let foreground =
5226 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-fable-5").unwrap();
5227 let orchestration =
5228 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap();
5229 rt.install_orchestration(Arc::new(orchestration));
5230 rt
5231 }
5232
5233 fn install_typed_manifest(rt: &mut Runtime) {
5234 let module = agent_core::prompt::PromptModule::new(
5235 agent_core::prompt::PromptModuleId::parse("kernel.test").unwrap(),
5236 "1.0.0",
5237 agent_core::prompt::PromptModuleSource::User,
5238 0,
5239 agent_core::prompt::PromptSelectors::default(),
5240 agent_core::prompt::ModuleMutability::MutableGuidance,
5241 "KERNEL.",
5242 )
5243 .unwrap();
5244 let context = agent_core::prompt::SelectionContext::new(
5245 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-fable-5").unwrap(),
5246 None,
5247 )
5248 .unwrap();
5249 rt.apply_prompt_stack(agent_core::prompt::PromptStack::new(vec![module], context).unwrap())
5250 .unwrap();
5251 }
5252
5253 #[test]
5254 fn provisioned_fable_accepts_max_and_ultracode() {
5255 for level in [ReasoningLevel::Max, ReasoningLevel::UltraCode] {
5256 let mut rt = provisioned_fable_runtime();
5257 rt.set_reasoning_level_checked(level).unwrap();
5258 assert_eq!(rt.reasoning_level(), level);
5259 }
5260 }
5261
5262 #[test]
5263 fn fable_ultracode_prerequisites_fail_without_mutation() {
5264 let mut missing_orchestration = Runtime::new_headless();
5265 missing_orchestration.set_model("anthropic/claude-fable-5".into());
5266 missing_orchestration.set_reasoning_level(ReasoningLevel::Low);
5267 assert!(missing_orchestration
5268 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5269 .is_err());
5270 assert_eq!(missing_orchestration.reasoning_level(), ReasoningLevel::Low);
5271
5272 let mut missing_tools = provisioned_fable_runtime();
5273 missing_tools.set_tools(ToolRegistry::without_subagent());
5274 assert!(missing_tools
5275 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5276 .is_err());
5277 assert_eq!(missing_tools.reasoning_level(), ReasoningLevel::Low);
5278
5279 let mut worker = provisioned_fable_runtime();
5280 worker.set_codex_request_role(crate::runtime::openai::catalog::ExecutionRole::Worker);
5281 assert!(worker
5282 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5283 .is_err());
5284 assert_eq!(worker.reasoning_level(), ReasoningLevel::Low);
5285
5286 let mut internal = provisioned_fable_runtime();
5287 internal.set_codex_request_role(crate::runtime::openai::catalog::ExecutionRole::Internal);
5288 assert!(internal
5289 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5290 .is_err());
5291 assert_eq!(internal.reasoning_level(), ReasoningLevel::Low);
5292
5293 let mut manifest = provisioned_fable_runtime();
5294 install_typed_manifest(&mut manifest);
5295 assert!(manifest
5296 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5297 .is_err());
5298 assert_eq!(manifest.reasoning_level(), ReasoningLevel::Low);
5299 }
5300
5301 #[test]
5302 fn checked_accepts_client_omission_modes_for_codex() {
5303 for level in [ReasoningLevel::Off, ReasoningLevel::Adaptive] {
5304 let mut rt = codex_runtime("gpt-5.6-sol");
5305 rt.set_reasoning_level_checked(level).unwrap();
5306 assert_eq!(rt.reasoning_level(), level);
5307 }
5308 }
5309
5310 #[test]
5312 fn checked_rejects_ultra_for_luna_no_mutation() {
5313 let mut rt = codex_runtime("gpt-5.6-luna");
5314 let before = rt.reasoning_level();
5315 let err = rt
5316 .set_reasoning_level_checked(ReasoningLevel::Ultra)
5317 .unwrap_err();
5318 assert!(
5319 err.contains("ultra"),
5320 "error must name the rejected level; got: {err}"
5321 );
5322 assert_eq!(
5323 rt.reasoning_level(),
5324 before,
5325 "runtime must not be mutated when validation fails"
5326 );
5327 }
5328
5329 #[test]
5331 fn checked_rejects_max_for_gpt55_no_mutation() {
5332 let mut rt = codex_runtime("gpt-5.5");
5333 rt.set_reasoning_level(ReasoningLevel::Medium);
5334 let err = rt
5335 .set_reasoning_level_checked(ReasoningLevel::Max)
5336 .unwrap_err();
5337 assert!(
5338 err.contains("max"),
5339 "error must name the rejected level; got: {err}"
5340 );
5341 assert_eq!(rt.reasoning_level(), ReasoningLevel::Medium);
5342 }
5343
5344 #[test]
5346 fn checked_accepts_ultra_for_sol_and_mutates() {
5347 let mut rt = codex_runtime("gpt-5.6-sol");
5348 rt.set_reasoning_level_checked(ReasoningLevel::Ultra)
5349 .expect("sol must accept ultra");
5350 assert_eq!(rt.reasoning_level(), ReasoningLevel::Ultra);
5351 }
5352
5353 #[test]
5355 fn checked_rejects_extended_levels_without_metadata() {
5356 let mut rt = Runtime::new_headless();
5357 rt.set_model("claude-opus-4-7".to_string());
5358 rt.set_reasoning_level(ReasoningLevel::Low);
5359 for level in [ReasoningLevel::Max, ReasoningLevel::Ultra] {
5360 assert!(rt.set_reasoning_level_checked(level).is_err());
5361 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
5362 }
5363 }
5364
5365 #[test]
5368 fn checked_rejects_max_ultra_for_unknown_codex_model() {
5369 let mut rt = codex_runtime("gpt-unknown-future");
5370 rt.set_reasoning_level(ReasoningLevel::Low);
5371 for level in [ReasoningLevel::Max, ReasoningLevel::Ultra] {
5372 let err = rt.set_reasoning_level_checked(level).unwrap_err();
5373 assert!(err.contains("no capability metadata"), "{err}");
5374 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
5375 }
5376 }
5377
5378 #[test]
5381 fn checked_enforces_xai_capability_matrix_no_mutation_on_err() {
5382 let mut rt = Runtime::new_headless();
5383 rt.set_model("xai-auth/grok-4.5".to_string());
5384 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5386 for level in [
5387 ReasoningLevel::Off,
5388 ReasoningLevel::XHigh,
5389 ReasoningLevel::Ultra,
5390 ] {
5391 let before = rt.reasoning_level();
5392 assert!(rt.set_reasoning_level_checked(level).is_err(), "{level}");
5393 assert_eq!(rt.reasoning_level(), before);
5394 }
5395 rt.set_reasoning_level_checked(ReasoningLevel::Low)
5396 .expect("grok-4.5 supports low");
5397 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
5398 }
5399
5400 #[test]
5403 fn checked_rejects_named_on_intrinsic_xai_model_and_defaults_adaptive() {
5404 let mut rt = Runtime::new_headless();
5405 rt.set_model("xai-auth/grok-4.3".to_string());
5406 assert_eq!(rt.reasoning_level(), ReasoningLevel::Adaptive);
5407 assert!(rt
5408 .set_reasoning_level_checked(ReasoningLevel::Medium)
5409 .is_err());
5410 assert_eq!(rt.reasoning_level(), ReasoningLevel::Adaptive);
5411 rt.set_reasoning_level_checked(ReasoningLevel::Adaptive)
5412 .unwrap();
5413 }
5414
5415 #[test]
5417 fn explicit_level_survives_xai_model_switch() {
5418 let mut rt = Runtime::new_headless();
5419 rt.set_model("xai-auth/grok-4.5".to_string());
5420 rt.set_reasoning_level_checked(ReasoningLevel::Low).unwrap();
5421 rt.set_model("xai-auth/grok-4.5-latest".to_string());
5422 assert_eq!(
5423 rt.reasoning_level(),
5424 ReasoningLevel::Low,
5425 "explicit level must not be overwritten by the model default"
5426 );
5427 }
5428
5429 #[test]
5432 fn explicit_unsupported_level_is_clamped_to_model_default_on_switch() {
5433 let mut rt = Runtime::new_headless();
5434 rt.set_reasoning_level_explicit(ReasoningLevel::XHigh);
5435 let clamp = rt
5436 .try_set_model("xai-auth/grok-4.6".to_string())
5437 .expect("model switch succeeds");
5438 assert_eq!(
5439 clamp,
5440 Some(ReasoningClamp {
5441 from: ReasoningLevel::XHigh,
5442 to: ReasoningLevel::High,
5443 })
5444 );
5445 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5446 assert!(
5447 rt.is_reasoning_explicit(),
5448 "clamp keeps explicit provenance"
5449 );
5450 assert_eq!(rt.model(), "xai-auth/grok-4.6");
5451 }
5452
5453 #[test]
5455 fn explicit_supported_level_is_not_clamped_on_switch() {
5456 let mut rt = Runtime::new_headless();
5457 rt.set_reasoning_level_explicit(ReasoningLevel::High);
5458 let clamp = rt.try_set_model("xai-auth/grok-4.6".to_string()).unwrap();
5459 assert_eq!(clamp, None);
5460 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5461 assert!(rt.is_reasoning_explicit());
5462 }
5463
5464 #[test]
5467 fn non_explicit_default_application_reports_no_clamp() {
5468 let mut rt = Runtime::new_headless();
5469 rt.set_reasoning_level(ReasoningLevel::XHigh);
5470 let clamp = rt.try_set_model("xai-auth/grok-4.6".to_string()).unwrap();
5471 assert_eq!(clamp, None);
5472 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5473 assert!(!rt.is_reasoning_explicit());
5474 }
5475
5476 #[test]
5479 fn explicit_max_clamps_to_validating_option_on_multi_agent_model() {
5480 let model = "xai-auth/grok-4.20-multi-agent-0309";
5481 let mut rt = Runtime::new_headless();
5482 rt.set_reasoning_level_explicit(ReasoningLevel::Max);
5483 let clamp = rt
5484 .try_set_model(model.to_string())
5485 .unwrap()
5486 .expect("Max is not supported: must clamp");
5487 assert_eq!(clamp.from, ReasoningLevel::Max);
5488 assert!(
5489 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
5490 model, clamp.to
5491 )
5492 .is_ok(),
5493 "clamped level {} must validate",
5494 clamp.to
5495 );
5496 assert_eq!(rt.reasoning_level(), clamp.to);
5497 assert!(rt.is_reasoning_explicit());
5498 }
5499
5500 #[test]
5502 fn set_model_returns_clamp() {
5503 let mut rt = Runtime::new_headless();
5504 rt.set_reasoning_level_explicit(ReasoningLevel::XHigh);
5505 let clamp = rt.set_model("xai-auth/grok-4.6".to_string());
5506 assert_eq!(clamp.map(|c| c.to), Some(ReasoningLevel::High));
5507 }
5508
5509 #[test]
5512 fn restore_session_reasoning_clamps_after_reapply() {
5513 let mut rt = Runtime::new_headless();
5514 rt.set_model("xai-auth/grok-4.6".to_string());
5515 let clamp = rt.restore_session_reasoning("xhigh");
5516 assert_eq!(
5517 clamp,
5518 Some(ReasoningClamp {
5519 from: ReasoningLevel::XHigh,
5520 to: ReasoningLevel::High,
5521 })
5522 );
5523 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5524 assert!(rt.is_reasoning_explicit());
5525 assert!(rt.set_reasoning_level_checked(rt.reasoning_level()).is_ok());
5526 }
5527
5528 #[test]
5530 fn restore_session_reasoning_keeps_supported_level() {
5531 let mut rt = Runtime::new_headless();
5532 rt.set_model("xai-auth/grok-4.6".to_string());
5533 assert_eq!(rt.restore_session_reasoning("low"), None);
5534 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
5535 assert!(rt.is_reasoning_explicit());
5536 }
5537
5538 #[test]
5541 fn apply_config_clamps_unsupported_explicit_level_against_config_model() {
5542 let mut rt = Runtime::new_headless();
5543 rt.apply_config(&crate::config::SynapsConfig {
5544 model: Some("xai-auth/grok-4.6".to_string()),
5545 thinking_level: Some(ReasoningLevel::XHigh),
5546 ..Default::default()
5547 });
5548 assert_eq!(rt.model(), "xai-auth/grok-4.6");
5549 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5550 assert!(rt.is_reasoning_explicit());
5551 assert!(rt.set_reasoning_level_checked(rt.reasoning_level()).is_ok());
5552 }
5553}
5554
5555#[cfg(test)]
5556mod codex_execution_preflight_tests {
5557 use super::*;
5558 use crate::runtime::openai::catalog::CodexRequestRole;
5559 use agent_core::prompt::QualifiedModelId;
5560 use agent_core::reasoning::ReasoningLevel;
5561
5562 fn ultra_runtime() -> Runtime {
5563 let mut runtime = Runtime::new_headless();
5564 runtime.set_model("openai-codex/gpt-5.6-sol".to_string());
5565 runtime.set_reasoning_level_explicit(ReasoningLevel::Ultra);
5566 runtime
5567 }
5568
5569 fn install_baseline(runtime: &mut Runtime) {
5570 let foreground =
5571 QualifiedModelId::parse("openai-codex/gpt-5.6-sol").expect("qualified model");
5572 let orchestration = crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8)
5573 .expect("baseline orchestration");
5574 runtime.install_orchestration(Arc::new(orchestration));
5575 }
5576
5577 #[tokio::test]
5578 async fn foreground_ultra_preflight_requires_orchestration() {
5579 let runtime = ultra_runtime();
5580 let error = runtime
5581 .validate_request_preflight()
5582 .await
5583 .expect_err("Ultra without orchestration must fail closed");
5584 assert!(error.to_string().contains("orchestration"), "{error}");
5585 }
5586
5587 #[tokio::test]
5588 async fn foreground_ultra_preflight_requires_actionable_subagent_tools() {
5589 let mut runtime = ultra_runtime();
5590 install_baseline(&mut runtime);
5591 runtime.set_tools(ToolRegistry::without_subagent());
5592
5593 let error = runtime
5594 .validate_request_preflight()
5595 .await
5596 .expect_err("Ultra without subagent tools must fail closed");
5597 assert!(error.to_string().contains("subagent"), "{error}");
5598 }
5599
5600 #[tokio::test]
5601 async fn foreground_ultra_preflight_accepts_exact_model_policy_and_tools() {
5602 let mut runtime = ultra_runtime();
5603 install_baseline(&mut runtime);
5604 runtime
5605 .validate_request_preflight()
5606 .await
5607 .expect("fully provisioned Ultra must pass preflight");
5608 }
5609
5610 #[tokio::test]
5611 async fn worker_ultra_preflight_never_requires_or_enables_orchestration() {
5612 let mut runtime = ultra_runtime();
5613 runtime.set_codex_request_role(CodexRequestRole::Worker);
5614 runtime.set_tools(ToolRegistry::without_subagent());
5615 runtime
5616 .validate_request_preflight()
5617 .await
5618 .expect("worker Ultra is underlying max without proactive orchestration");
5619 assert_eq!(runtime.codex_request_role(), CodexRequestRole::Worker);
5620 }
5621
5622 #[tokio::test]
5623 async fn non_codex_ultra_preflight_fails_before_provider_work() {
5624 let mut runtime = Runtime::new_headless();
5625 runtime.set_model("anthropic/claude-opus-4-7".to_string());
5626 runtime.set_reasoning_level_explicit(ReasoningLevel::Ultra);
5627 let error = runtime
5628 .validate_request_preflight()
5629 .await
5630 .expect_err("non-Codex Ultra must fail closed");
5631 assert!(
5632 error.to_string().contains("unsupported_reasoning_level"),
5633 "{error}"
5634 );
5635 }
5636
5637 #[tokio::test]
5638 async fn compaction_preflight_validates_target_before_credentials() {
5639 let mut runtime = ultra_runtime();
5640 runtime.set_compaction_model(Some("claude-sonnet-4-6".to_string()));
5641 {
5642 let mut auth = runtime.auth.write().await;
5643 auth.auth_token.clear();
5644 auth.auth_type = "none".to_string();
5645 auth.refresh_token = None;
5646 auth.token_expires = None;
5647 }
5648
5649 let error = runtime
5650 .compact_call(Vec::new())
5651 .await
5652 .expect_err("Codex Ultra cannot silently cross into Anthropic compaction");
5653 assert!(
5654 error.to_string().contains("authoritative exact-model"),
5655 "preflight must reject the target before auth access: {error}"
5656 );
5657 }
5658}
5659
5660#[cfg(test)]
5661mod http_client_timeout_tests {
5662 use futures::StreamExt;
5676 use std::time::Duration;
5677 use tokio::io::AsyncWriteExt;
5678
5679 const CHUNKED_HEAD: &[u8] =
5680 b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ntransfer-encoding: chunked\r\n\r\n";
5681
5682 fn chunk(data: &str) -> Vec<u8> {
5683 format!("{:x}\r\n{}\r\n", data.len(), data).into_bytes()
5684 }
5685
5686 async fn spawn_chunked_server(n_chunks: usize, gap: Duration, terminate: bool) -> String {
5689 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5690 let addr = listener.local_addr().unwrap();
5691 tokio::spawn(async move {
5692 let (mut sock, _) = listener.accept().await.unwrap();
5693 let mut buf = [0u8; 4096];
5695 use tokio::io::AsyncReadExt;
5696 let _ = sock.read(&mut buf).await;
5697 sock.write_all(CHUNKED_HEAD).await.unwrap();
5698 for i in 0..n_chunks {
5699 sock.write_all(&chunk(&format!("data {i}\n")))
5700 .await
5701 .unwrap();
5702 sock.flush().await.unwrap();
5703 tokio::time::sleep(gap).await;
5704 }
5705 if terminate {
5706 sock.write_all(b"0\r\n\r\n").await.unwrap();
5707 } else {
5708 tokio::time::sleep(Duration::from_secs(60)).await;
5710 }
5711 });
5712 format!("http://{addr}/stream")
5713 }
5714
5715 async fn drain(
5716 client: &reqwest::Client,
5717 url: &str,
5718 ) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
5719 let resp = client.get(url).send().await?;
5720 let mut stream = resp.bytes_stream();
5721 let mut total = 0usize;
5722 while let Some(c) = stream.next().await {
5723 total += c?.len();
5724 }
5725 Ok(total)
5726 }
5727
5728 #[tokio::test]
5733 async fn active_stream_outliving_read_timeout_survives() {
5734 let url = spawn_chunked_server(15, Duration::from_millis(100), true).await;
5736 let client = super::build_http_client(Duration::from_millis(400)).expect("client builds");
5737 let total = drain(&client, &url)
5738 .await
5739 .expect("active stream must never be killed by the idle timeout");
5740 assert!(total > 0);
5741 }
5742
5743 #[tokio::test]
5746 async fn stalled_stream_is_killed_by_read_timeout() {
5747 let url = spawn_chunked_server(2, Duration::from_millis(10), false).await;
5749 let client = super::build_http_client(Duration::from_millis(300)).expect("client builds");
5750 let start = std::time::Instant::now();
5751 let err = drain(&client, &url)
5752 .await
5753 .expect_err("stalled stream must be detected");
5754 assert!(
5755 start.elapsed() < Duration::from_secs(5),
5756 "stall must be detected promptly, took {:?}",
5757 start.elapsed()
5758 );
5759 let msg = crate::core::error::error_chain_string(err.as_ref());
5760 assert!(
5761 msg.contains("timed out") || msg.contains("timeout"),
5762 "must be a timeout error: {msg}"
5763 );
5764 }
5765
5766 #[tokio::test]
5770 async fn hung_headers_are_killed_by_read_timeout() {
5771 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5772 let addr = listener.local_addr().unwrap();
5773 tokio::spawn(async move {
5774 let (_sock, _) = listener.accept().await.unwrap();
5775 tokio::time::sleep(Duration::from_secs(60)).await;
5776 });
5777 let client = super::build_http_client(Duration::from_millis(300)).expect("client builds");
5778 let start = std::time::Instant::now();
5779 let err = client
5780 .get(format!("http://{addr}/codex/responses"))
5781 .send()
5782 .await
5783 .expect_err("hung request must be detected");
5784 assert!(
5785 start.elapsed() < Duration::from_secs(5),
5786 "hung headers must be detected promptly, took {:?}",
5787 start.elapsed()
5788 );
5789 assert!(err.is_timeout(), "must be a timeout: {err:?}");
5790 }
5791}
5792
5793#[cfg(test)]
5795mod memory_context_provider_tests {
5796 use super::{memory_context, Runtime};
5797 use std::sync::Arc;
5798
5799 async fn memory_runtime_with_providers(
5807 plugins: &[(&str, &str)],
5808 ) -> (
5809 Runtime,
5810 std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
5811 ) {
5812 let mut manager = crate::extensions::manager::ExtensionManager::new(Arc::new(
5813 crate::extensions::hooks::HookBus::new(),
5814 ));
5815 manager.set_progressive_deferral(true);
5816 for (plugin, provider) in plugins {
5817 let manifest: crate::extensions::manifest::ExtensionManifest =
5818 serde_json::from_value(serde_json::json!({
5819 "runtime": "process",
5820 "command": "/bin/false",
5821 "permissions": ["context_providers.register"],
5822 "deferred": {
5823 "context_providers": [{
5824 "id": provider,
5825 "capability": "project-memory",
5826 "description": "test context provider",
5827 "schema_version": 1
5828 }]
5829 }
5830 }))
5831 .expect("manifest parses");
5832 manager
5833 .load(plugin, &manifest)
5834 .await
5835 .expect("deferred context-provider load succeeds without spawning");
5836 }
5837 let extension_runtime = manager.extension_runtime();
5838 let mut runtime = Runtime::new_headless();
5839 runtime.install_extension_runtime(std::sync::Arc::clone(&extension_runtime));
5840 (runtime, extension_runtime)
5841 }
5842
5843 fn assert_memory_off_no_lease(runtime: &Runtime) {
5844 let status = runtime.memory_context_status();
5845 assert_eq!(
5846 status.durable,
5847 memory_context::DurableStatus::Off,
5848 "state must stay Off"
5849 );
5850 assert_eq!(
5851 status.one_shot,
5852 memory_context::OneShotStatus::Idle,
5853 "no one-shot lease may exist"
5854 );
5855 assert!(
5856 runtime.memory_bound_providers_for_test().is_empty(),
5857 "no provider may be bound"
5858 );
5859 }
5860
5861 #[tokio::test]
5864 async fn memory_backend_exclusive_rejects_extension_recall_capture_and_history() {
5865 for selector in ["axel", "invalid-selector"] {
5866 let (mut runtime, _manager) =
5867 memory_runtime_with_providers(&[("memory-test", "notes")]).await;
5868 let provider = runtime.resolve_memory_provider(None).unwrap();
5869 runtime.apply_config(&agent_core::config::load_config_from_str(&format!(
5870 "memory.backend = {selector}\n"
5871 )));
5872 assert!(runtime.memory_backend.exclusive());
5873 assert!(matches!(
5874 runtime.resolve_memory_provider(None),
5875 Err(memory_context::MemoryContextError::ProviderNotRegistered)
5876 ));
5877 assert!(matches!(
5878 runtime.resolve_memory_provider(Some(provider.as_str())),
5879 Err(memory_context::MemoryContextError::ProviderNotRegistered)
5880 ));
5881 assert!(matches!(
5882 runtime.memory_history_confirm(),
5883 Err(
5884 super::memory_history::HistoryImportError::CaptureProviderUnavailable
5885 | super::memory_history::HistoryImportError::ConsentRequired
5886 )
5887 ));
5888 assert_memory_off_no_lease(&runtime);
5889 let mut messages = vec![Arc::new(
5890 serde_json::json!({"role": "user", "content": "test"}),
5891 )];
5892 let original = messages.clone();
5893 runtime.apply_turn_memory_recall(&mut messages).await;
5894 assert_eq!(messages, original);
5895 let clone = runtime.clone();
5896 assert!(clone.memory_backend.exclusive());
5897 assert_eq!(clone.memory_backend.base(), runtime.memory_backend.base());
5898 }
5899 }
5900
5901 #[tokio::test]
5902 async fn memory_backend_first_config_is_immutable_and_changes_require_restart() {
5903 for (first, different) in [("legacy", "axel"), ("axel", "legacy")] {
5904 let mut runtime = Runtime::new_headless();
5905 let config =
5906 agent_core::config::load_config_from_str(&format!("memory.backend = {first}\n"));
5907 runtime.apply_config(&config);
5908 let binding = runtime.memory_backend.clone();
5909 runtime.apply_config(&config);
5910 assert!(!runtime
5911 .memory_backend_reconfigure_denied
5912 .load(std::sync::atomic::Ordering::SeqCst));
5913 if let (Ok(a), Ok(b)) = (binding.scope(), runtime.memory_backend.scope()) {
5914 assert!(std::ptr::eq(a, b));
5915 }
5916 let clone = runtime.clone();
5917 runtime.apply_config(&agent_core::config::load_config_from_str(&format!(
5918 "memory.backend = {different}\n"
5919 )));
5920 assert_eq!(runtime.memory_backend.exclusive(), binding.exclusive());
5921 assert_eq!(
5922 runtime.memory_backend_config.as_ref(),
5923 Some(&config.memory_backend)
5924 );
5925 for denied in [&runtime, &clone] {
5926 let error = denied
5927 .validate_request_preflight()
5928 .await
5929 .unwrap_err()
5930 .to_string();
5931 assert!(error.contains("restart required"), "{error}");
5932 assert!(denied.resolve_memory_provider(None).is_err());
5933 }
5934 runtime.apply_config(&config);
5935 assert!(runtime.validate_request_preflight().await.is_err());
5936 }
5937 }
5938
5939 #[tokio::test]
5940 async fn memory_backend_path_changes_also_require_restart() {
5941 for field in ["executable", "brain"] {
5942 let mut runtime = Runtime::new_headless();
5943 let config = crate::config::MemoryBackendConfig::default();
5944 runtime.apply_memory_backend_config(&config);
5945 let mut different = config.clone();
5946 let path = std::env::temp_dir().join("synaps-memory-config-test");
5947 if field == "executable" {
5948 different.executable = Some(path);
5949 } else {
5950 different.brain = Some(path);
5951 }
5952 runtime.apply_memory_backend_config(&different);
5953 assert_eq!(runtime.memory_backend_config.as_ref(), Some(&config));
5954 assert!(!runtime.memory_backend.exclusive());
5955 let error = runtime
5956 .validate_request_preflight()
5957 .await
5958 .unwrap_err()
5959 .to_string();
5960 assert!(error.contains("restart required"), "{error}");
5961 assert!(runtime.resolve_memory_provider(None).is_err());
5962 }
5963 }
5964
5965 #[test]
5966 fn memory_backend_apply_config_revokes_preexisting_legacy_lease() {
5967 let mut runtime = Runtime::new_headless();
5968 runtime
5969 .memory_context_enable(
5970 memory_context::MemoryContextMode::CaptureAndRecall,
5971 memory_context::UserIntentProof::ExplicitCommand {
5972 command_id: memory_context::mint_explicit_command_id(),
5973 },
5974 )
5975 .unwrap();
5976 let lease = runtime
5977 .memory_context_lock()
5978 .capture_lease_at(std::time::SystemTime::now())
5979 .unwrap();
5980 runtime.apply_config(&agent_core::config::load_config_from_str(
5981 "memory.backend = axel\n",
5982 ));
5983 assert_memory_off_no_lease(&runtime);
5984 assert!(runtime.extension_capture_provider(&lease).is_none());
5985 }
5986
5987 #[test]
5988 fn axel_invalid_config_cannot_grant_consent_or_fallback() {
5989 let mut runtime = Runtime::new_headless();
5990 runtime.apply_config(&agent_core::config::load_config_from_str(
5991 "memory.backend = invalid-not-axel-not-legacy\n",
5992 ));
5993 assert!(runtime.memory_backend.exclusive());
5994 assert!(runtime.resolve_memory_provider(None).is_err());
5995 assert_memory_off_no_lease(&runtime);
5996 }
5997
5998 #[tokio::test]
6004 async fn memory_enable_unregistered_provider_fails_closed_grants_nothing() {
6005 let (runtime, _ext) =
6007 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
6008 for requested in ["no-such-provider", "bad:id"] {
6009 let err = runtime
6010 .memory_context_enable_resolved(
6011 memory_context::MemoryContextMode::CaptureAndRecall,
6012 memory_context::mint_explicit_command_proof(),
6013 Some(requested),
6014 )
6015 .expect_err("unregistered provider must fail closed");
6016 assert_eq!(
6017 err,
6018 memory_context::MemoryContextError::ProviderNotRegistered
6019 );
6020 assert_memory_off_no_lease(&runtime);
6021 }
6022
6023 let (runtime, _ext) = memory_runtime_with_providers(&[]).await;
6026 let err = runtime
6027 .memory_context_enable(
6028 memory_context::MemoryContextMode::CaptureAndRecall,
6029 memory_context::mint_explicit_command_proof(),
6030 )
6031 .expect_err("empty catalog must fail closed");
6032 assert_eq!(
6033 err,
6034 memory_context::MemoryContextError::ProviderNotRegistered
6035 );
6036 assert_memory_off_no_lease(&runtime);
6037 let err = runtime
6039 .memory_context_recall_once(memory_context::mint_explicit_command_proof())
6040 .expect_err("one-shot grant must validate the provider too");
6041 assert_eq!(
6042 err,
6043 memory_context::MemoryContextError::ProviderNotRegistered
6044 );
6045 assert_memory_off_no_lease(&runtime);
6046 }
6047
6048 #[tokio::test]
6052 async fn memory_enable_exactly_one_declared_provider_records_exact_identity() {
6053 const ADDRESS: &str = "extension:axel-memory-manager:project-memory";
6054 let (runtime, _ext) =
6056 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
6057 let status = runtime
6058 .memory_context_enable(
6059 memory_context::MemoryContextMode::CaptureAndRecall,
6060 memory_context::mint_explicit_command_proof(),
6061 )
6062 .expect("unique declared provider must resolve");
6063 assert!(matches!(
6064 status.durable,
6065 memory_context::DurableStatus::Active { .. }
6066 ));
6067 let bound = runtime.memory_bound_providers_for_test();
6068 assert_eq!(bound.len(), 1);
6069 assert_eq!(bound[0].as_str(), ADDRESS, "exact runtime address recorded");
6070
6071 let (runtime, _ext) =
6073 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
6074 runtime
6075 .memory_context_enable_resolved(
6076 memory_context::MemoryContextMode::RecallEachPrompt,
6077 memory_context::mint_explicit_command_proof(),
6078 Some("project-memory"),
6079 )
6080 .expect("explicit exact id must resolve");
6081 assert_eq!(
6082 runtime.memory_bound_providers_for_test()[0].as_str(),
6083 ADDRESS
6084 );
6085 }
6086
6087 #[tokio::test]
6091 async fn memory_enable_ambiguous_overlapping_declarations_fails_closed() {
6092 let (runtime, _ext) = memory_runtime_with_providers(&[
6093 ("mem-a", "project-memory"),
6094 ("mem-b", "project-memory"),
6095 ])
6096 .await;
6097 let err = runtime
6098 .memory_context_enable(
6099 memory_context::MemoryContextMode::CaptureAndRecall,
6100 memory_context::mint_explicit_command_proof(),
6101 )
6102 .expect_err("overlapping declarations must fail closed");
6103 assert_eq!(err, memory_context::MemoryContextError::ProviderAmbiguous);
6104 assert_memory_off_no_lease(&runtime);
6105
6106 let err = runtime
6108 .memory_context_enable_resolved(
6109 memory_context::MemoryContextMode::CaptureOnly,
6110 memory_context::mint_explicit_command_proof(),
6111 Some("project-memory"),
6112 )
6113 .expect_err("same id declared by two plugins is ambiguous");
6114 assert_eq!(err, memory_context::MemoryContextError::ProviderAmbiguous);
6115 assert_memory_off_no_lease(&runtime);
6116 }
6117
6118 #[tokio::test]
6124 async fn memory_disable_revokes_runtime_lease_without_spawn_no_panic() {
6125 let (runtime, ext) =
6126 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
6127 runtime
6128 .memory_context_enable(
6129 memory_context::MemoryContextMode::CaptureAndRecall,
6130 memory_context::mint_explicit_command_proof(),
6131 )
6132 .expect("enable succeeds");
6133 assert_eq!(
6134 ext.lease_count(),
6135 0,
6136 "granting a memory lease spawns nothing"
6137 );
6138
6139 let status = runtime.memory_context_disable();
6140 assert_eq!(status.durable, memory_context::DurableStatus::Off);
6141 assert_memory_off_no_lease(&runtime);
6142 assert_eq!(
6143 ext.lease_count(),
6144 0,
6145 "revocation of a never-spawned lease is a no-op"
6146 );
6147
6148 let status = runtime.memory_context_disable();
6150 assert_eq!(status.durable, memory_context::DurableStatus::Off);
6151 assert_eq!(ext.lease_count(), 0);
6152 }
6153
6154 #[test]
6159 fn memory_context_enable_disable_emit_metadata_only_events() {
6160 memory_context::drain_captured_memory_events_for_test();
6161
6162 let runtime = Runtime::new_headless();
6163 runtime
6165 .memory_context_enable(
6166 memory_context::MemoryContextMode::Off,
6167 memory_context::mint_explicit_command_proof(),
6168 )
6169 .expect_err("Off is not an enable mode");
6170 assert!(
6171 memory_context::drain_captured_memory_events_for_test().is_empty(),
6172 "failed enable must emit no event"
6173 );
6174
6175 runtime
6176 .memory_context_enable(
6177 memory_context::MemoryContextMode::CaptureAndRecall,
6178 memory_context::mint_explicit_command_proof(),
6179 )
6180 .expect("enable succeeds");
6181 runtime.memory_context_disable();
6182
6183 let events = memory_context::drain_captured_memory_events_for_test();
6184 assert_eq!(
6185 events
6186 .iter()
6187 .map(|event| (event.event, event.outcome))
6188 .collect::<Vec<_>>(),
6189 vec![
6190 ("memory_context.enabled", "enabled"),
6191 ("memory_context.disabled", "disabled"),
6192 ],
6193 );
6194 assert_eq!(events[0].mode, Some("capture_and_recall"));
6195 assert_eq!(events[1].mode, Some("off"));
6196
6197 let cwd = std::env::current_dir()
6200 .expect("cwd")
6201 .to_string_lossy()
6202 .into_owned();
6203 for event in &events {
6204 let json = serde_json::to_string(event).expect("event serializes");
6205 assert!(
6206 json.contains("\"project_digest\":\"p") || json.contains("project-cwd-"),
6207 "digest identity expected; got: {json}"
6208 );
6209 assert!(
6210 !json.contains(&cwd),
6211 "raw project path leaked into event JSON: {json}"
6212 );
6213 }
6214 }
6215
6216 #[tokio::test]
6222 async fn memory_session_end_guard_drop_fresh_construction_is_off_no_lease() {
6223 let (runtime, ext) =
6224 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
6225 runtime
6226 .memory_context_enable(
6227 memory_context::MemoryContextMode::CaptureAndRecall,
6228 memory_context::mint_explicit_command_proof(),
6229 )
6230 .expect("enable succeeds");
6231 drop(runtime);
6233 assert_eq!(
6234 ext.lease_count(),
6235 0,
6236 "session end must leave no runtime lease (never-spawned reap is a no-op)"
6237 );
6238
6239 let mut fresh = Runtime::new_headless();
6241 fresh.install_extension_runtime(std::sync::Arc::clone(&ext));
6242 assert_memory_off_no_lease(&fresh);
6243 assert_eq!(ext.lease_count(), 0);
6244 }
6245}