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, LlmEvent, SessionEvent, StreamEvent};
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 session_id: Option<String>,
496 cwd: Option<PathBuf>,
500 env: Option<crate::session::types::SessionEnv>,
504 env_stripped: Vec<String>,
506 env_warned: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
510}
511
512fn fresh_host_tool_session() -> crate::tools::activation::SessionId {
516 crate::tools::activation::SessionId::parse(&format!(
517 "runtime-{}-{}",
518 std::process::id(),
519 uuid::Uuid::new_v4()
520 ))
521 .expect("generated runtime session id is always valid")
522}
523
524fn fresh_memory_context_state(
531) -> std::sync::Arc<std::sync::Mutex<memory_context::SessionMemoryState>> {
532 let session = memory_context::SessionId::parse(&format!(
533 "memctx-{}-{}",
534 std::process::id(),
535 uuid::Uuid::new_v4()
536 ))
537 .expect("generated memory session id is always valid");
538 std::sync::Arc::new(std::sync::Mutex::new(
539 memory_context::SessionMemoryState::new(session),
540 ))
541}
542
543fn memory_project_scope() -> std::result::Result<agent_core::memory::store::ProjectScope, String> {
547 let cwd = std::env::current_dir()
548 .map_err(|error| format!("cannot resolve current project directory: {error}"))?;
549 agent_core::memory::store::ProjectScope::discover(&cwd)
550 .map_err(|error| format!("cannot resolve project scope: {error}"))
551}
552
553fn memory_project_id() -> memory_context::ProjectId {
554 match memory_project_scope() {
555 Ok(scope) => memory_context::ProjectId::parse(scope.key())
556 .expect("host project scope key is always a valid identifier"),
557 Err(_) => memory_context::ProjectId::parse("project-unresolved")
558 .expect("static fallback project id is valid"),
559 }
560}
561
562fn memory_capture_worker() -> &'static capture_worker::CaptureWorker {
571 static WORKER: std::sync::OnceLock<capture_worker::CaptureWorker> = std::sync::OnceLock::new();
572 WORKER.get_or_init(|| capture_worker::CaptureWorker::new(32))
573}
574
575struct ExtensionCaptureProvider {
576 manager: std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
577 session: crate::tools::activation::SessionId,
578 plugin: String,
579 digest: crate::tools::catalog::SchemaDigest,
580 handle: tokio::runtime::Handle,
581}
582
583impl capture_worker::CaptureProvider for ExtensionCaptureProvider {
584 fn capture(
585 &self,
586 capture: chat_capture::ChatTurnCapture,
587 ) -> std::result::Result<(), capture_worker::CaptureFailure> {
588 let params = memory_context::capture_request_wire(&capture);
589 self.handle
590 .block_on(
591 crate::extensions::lease::ExtensionLeaseCapability::new(
592 self.session.clone(),
593 self.manager.clone(),
594 )
595 .call_exact(
596 &self.plugin,
597 memory_context::MEMORY_CAPTURE_TOOL_NAME,
598 &self.digest,
599 params,
600 ),
601 )
602 .map(|_| ())
603 .map_err(|_| capture_worker::CaptureFailure {
604 code: "provider_call_failed",
605 })
606 }
607
608 fn contains_capture(
609 &self,
610 capture_id: &[u8; 32],
611 ) -> std::result::Result<capture_worker::CaptureCommitState, capture_worker::CaptureFailure>
612 {
613 let capture_id = chat_capture::CaptureId::from_bytes(*capture_id);
614 let response = self
615 .handle
616 .block_on(
617 crate::extensions::lease::ExtensionLeaseCapability::new(
618 self.session.clone(),
619 self.manager.clone(),
620 )
621 .call_exact(
622 &self.plugin,
623 memory_context::MEMORY_CAPTURE_TOOL_NAME,
624 &self.digest,
625 memory_context::capture_query_wire(&capture_id),
626 ),
627 )
628 .map_err(|_| capture_worker::CaptureFailure {
629 code: "provider_query_failed",
630 })?;
631 Ok(
632 if response
633 .get("committed")
634 .and_then(serde_json::Value::as_bool)
635 == Some(true)
636 {
637 capture_worker::CaptureCommitState::Committed
638 } else {
639 capture_worker::CaptureCommitState::Absent
640 },
641 )
642 }
643
644 fn capture_summary(
645 &self,
646 capture: chat_capture::ConversationSummaryCapture,
647 ) -> std::result::Result<(), capture_worker::CaptureFailure> {
648 let params = memory_context::summary_capture_request_wire(&capture);
649 self.handle
650 .block_on(
651 crate::extensions::lease::ExtensionLeaseCapability::new(
652 self.session.clone(),
653 self.manager.clone(),
654 )
655 .call_exact(
656 &self.plugin,
657 memory_context::MEMORY_CAPTURE_TOOL_NAME,
658 &self.digest,
659 params,
660 ),
661 )
662 .map(|_| ())
663 .map_err(|_| capture_worker::CaptureFailure {
664 code: "provider_call_failed",
665 })
666 }
667}
668
669fn terminal_capture_history(
670 lease: &memory_context::MemoryContextLease,
671 messages: &[crate::SharedMessage],
672 started_at: std::time::SystemTime,
673) -> chat_capture::TerminalTurnHistory {
674 use agent_core::core::disclosure::DisclosureClass;
675 let mut items = Vec::new();
676 for message in messages {
677 let Some(role) = message.get("role").and_then(serde_json::Value::as_str) else {
678 continue;
679 };
680 let class = match role {
681 "user" => chat_capture::CaptureContentClass::UserMessage,
682 "assistant" => chat_capture::CaptureContentClass::AssistantFinal,
683 _ => continue,
684 };
685 let text = message
686 .get("content")
687 .and_then(serde_json::Value::as_str)
688 .map(str::to_owned)
689 .unwrap_or_else(|| {
690 message
691 .get("content")
692 .map(ToString::to_string)
693 .unwrap_or_default()
694 });
695 items.push(chat_capture::CanonicalCaptureItem {
696 project_id: lease.project_id.clone(),
697 class,
698 disclosure: DisclosureClass::ModelVisible,
699 sensitivity: chat_capture::Sensitivity::Normal,
700 text,
701 tool_name: None,
702 });
703 }
704 chat_capture::TerminalTurnHistory {
705 project_id: lease.project_id.clone(),
706 session_id: lease.session_id.clone(),
707 turn_id: memory_context::TurnId::parse(&format!("turn-{}", uuid::Uuid::new_v4()))
708 .expect("generated turn id"),
709 turn_ordinal: 0,
710 started_at,
711 completed_at: std::time::SystemTime::now(),
712 outcome: agent_core::TurnOutcome::Completed,
713 items,
714 compaction: None,
715 }
716}
717
718fn memory_provider_id() -> memory_context::ContextProviderId {
719 memory_context::ContextProviderId::parse("axel-memory")
720 .expect("static provider id is always valid")
721}
722
723fn terminal_capture_start(messages: &[crate::SharedMessage]) -> Option<crate::SharedMessage> {
724 messages
725 .iter()
726 .rfind(|message| {
727 message["role"] == "user"
728 && (message["content"].is_string()
729 || message["content"].as_array().is_some_and(|blocks| {
730 blocks.iter().any(|block| block["type"] != "tool_result")
731 }))
732 })
733 .cloned()
734}
735
736fn terminal_capture_messages<'a>(
737 messages: &'a [crate::SharedMessage],
738 start: &crate::SharedMessage,
739) -> Option<&'a [crate::SharedMessage]> {
740 let index = messages
744 .iter()
745 .position(|message| Arc::ptr_eq(message, start))?;
746 Some(&messages[index..])
747}
748
749fn dispatch_completed_terminal_capture(
752 completed: bool,
753 final_history: &std::sync::Mutex<Option<Vec<crate::SharedMessage>>>,
754 state: &std::sync::Mutex<memory_context::SessionMemoryState>,
755 selected: Option<(
756 memory_context::MemoryContextLease,
757 Arc<dyn capture_worker::CaptureProvider>,
758 )>,
759 started_at: std::time::SystemTime,
760 turn_start: Option<&crate::SharedMessage>,
761) -> bool {
762 let history = final_history
763 .lock()
764 .unwrap_or_else(std::sync::PoisonError::into_inner)
765 .take();
766 let (Some(messages), Some((lease, provider))) = (history, selected) else {
767 return false;
768 };
769 if !completed {
770 return false;
771 }
772 let Some(messages) = turn_start.and_then(|start| terminal_capture_messages(&messages, start))
773 else {
774 return false;
775 };
776 let state = state
777 .lock()
778 .unwrap_or_else(std::sync::PoisonError::into_inner);
779 if state
780 .capture_lease_at(std::time::SystemTime::now())
781 .as_ref()
782 != Some(&lease)
783 {
784 return false;
785 }
786 memory_capture_worker()
787 .submit_terminal(
788 &lease,
789 terminal_capture_history(&lease, messages, started_at),
790 memory_context::RetentionClass::Standard,
791 provider,
792 )
793 .unwrap_or(false)
794}
795
796const HTTP_READ_TIMEOUT: Duration = Duration::from_secs(90);
801
802fn build_http_client(read_timeout: Duration) -> reqwest::Result<Client> {
815 Client::builder()
816 .tls_built_in_webpki_certs(true)
817 .tls_built_in_native_certs(true)
818 .connect_timeout(Duration::from_secs(10))
819 .read_timeout(read_timeout)
820 .build()
821}
822
823pub fn build_host_http_client() -> Result<Client> {
827 build_http_client(HTTP_READ_TIMEOUT)
828 .map_err(|e| RuntimeError::Config(format!("Failed to build HTTP client: {}", e)))
829}
830
831pub(crate) struct RuntimeParts {
835 pub host: crate::host::HostParts,
836 pub session_manager: Arc<crate::tools::shell::SessionManager>,
837 pub reaper: Option<(tokio::task::JoinHandle<()>, CancellationToken)>,
838}
839
840impl RuntimeParts {
841 pub(crate) fn with_reaper(host: crate::host::HostParts) -> Self {
843 let session_manager = fresh_session_manager();
844 let cancel = CancellationToken::new();
845 let handle =
846 crate::tools::shell::session::start_reaper(session_manager.clone(), cancel.clone());
847 Self {
848 host,
849 session_manager,
850 reaper: Some((handle, cancel)),
851 }
852 }
853
854 #[cfg_attr(not(any(test, feature = "testing")), allow(dead_code))]
856 pub(crate) fn without_reaper(host: crate::host::HostParts) -> Self {
857 Self {
858 host,
859 session_manager: fresh_session_manager(),
860 reaper: None,
861 }
862 }
863}
864
865fn fresh_session_manager() -> Arc<crate::tools::shell::SessionManager> {
866 let config = crate::tools::shell::ShellConfig::default();
867 crate::tools::shell::SessionManager::new(config)
868}
869
870fn fresh_host_parts() -> Result<crate::host::HostParts> {
874 let client = build_host_http_client()?;
875 let capture_dir = trace::default_capture_dir();
880 let _ = trace::sweep_expired_captures(&capture_dir);
881 Ok(crate::host::HostParts {
882 client,
883 tools: Arc::new(RwLock::new(ToolRegistry::new())),
884 hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
885 credential_source: crate::auth::CredentialSource::Local,
886 token_cache: crate::auth::TokenCache::new(),
887 mcp_runtime: None,
888 extension_runtime: None,
889 capture_dir,
890 progressive_tool_disclosure: false,
891 })
892}
893
894fn canonical_trusted_worker_model(model: &str) -> String {
898 let model = model.trim();
899 if let Some(id) = model.strip_prefix("claude/") {
900 format!("anthropic/{id}")
901 } else if model.starts_with("claude-") {
902 format!("anthropic/{model}")
903 } else {
904 model.to_owned()
905 }
906}
907
908impl Drop for Runtime {
912 fn drop(&mut self) {
913 if let Some(c) = &self.reaper_cancel {
914 c.cancel();
915 }
916 }
917}
918
919impl Runtime {
920 pub async fn new() -> Result<Self> {
921 Ok(Self::from_parts(RuntimeParts::with_reaper(
923 fresh_host_parts()?,
924 )))
925 }
926
927 pub(crate) fn from_parts(parts: RuntimeParts) -> Self {
930 let (auth_token, auth_type, refresh_token, token_expires) =
934 (String::new(), "oauth".to_string(), None, Some(0));
935 let RuntimeParts {
936 host,
937 session_manager,
938 reaper,
939 } = parts;
940 let (reaper_handle, reaper_cancel) = match reaper {
941 Some((h, c)) => (Some(h), Some(c)),
942 None => (None, None),
943 };
944 let mcp_runtime = host.mcp_runtime;
945 let extension_runtime = host.extension_runtime;
946
947 let mut runtime = Runtime {
948 client: host.client,
949 auth: Arc::new(RwLock::new(AuthState {
950 auth_token,
951 auth_type,
952 refresh_token,
953 token_expires,
954 })),
955 model: crate::models::default_model().to_string(),
956 tools: host.tools,
957 system_prompt: None,
958 effective_prompt: None,
959 prompt_generation: 0,
960 prompt_reload_source: None,
961 thinking_budget: 4096,
962 named_level: None,
963 explicit_reasoning: false,
964 codex_request_role: crate::runtime::openai::catalog::CodexRequestRole::Foreground,
965 context_window_override: None,
966 compaction_model: None,
967 compaction_mode: agent_core::compaction::CompactionMode::default(),
968 compaction_exclusions: Vec::new(),
969 remote_summarization_attempts: Arc::new(std::sync::atomic::AtomicU64::new(0)),
970 subagent_registry: Arc::new(Mutex::new(
971 crate::runtime::subagent::SubagentRegistry::new(),
972 )),
973 orchestration: None,
974 event_queue: Arc::new(crate::events::EventQueue::new(1000)),
975 watcher_exit_path: None,
976 max_tool_output: 30000,
977 bash_timeout: 30,
978 bash_max_timeout: 300,
979 subagent_timeout: 300,
980 api_retries: 3,
981 refusal_retries: 2,
982 telemetry_level: crate::runtime::telemetry::TelemetryLevel::Off,
983 telemetry_writer: None,
984 trace_ctx: trace::TraceContext::disabled(),
985 trace_controls: std::sync::Arc::new(trace::TraceControls::new()),
986 memory_context_state: fresh_memory_context_state(),
989 memory_backend: crate::memory_backend::MemoryBinding::legacy_current(),
991 memory_backend_config: None,
992 memory_backend_reconfigure_denied: Arc::new(std::sync::atomic::AtomicBool::new(false)),
993 continuation: Arc::new(Mutex::new(continuation::ContinuationState::default())),
994 pending_history_import_preview: std::sync::Arc::new(std::sync::Mutex::new(None)),
995 history_import_plan: std::sync::Arc::new(std::sync::Mutex::new(None)),
996 #[cfg(any(test, feature = "testing"))]
997 capture_provider_for_test: std::sync::Arc::new(std::sync::Mutex::new(None)),
998 pending_memory_segment: std::sync::Arc::new(std::sync::Mutex::new(None)),
1001 retained_recall_turn: std::sync::Arc::new(std::sync::Mutex::new(None)),
1002 one_shot_trace_writer: std::sync::Arc::new(std::sync::Mutex::new(None)),
1003 capture_dir: host.capture_dir,
1004 cache_diagnostics: false,
1005 cache_ttl: crate::core::config::CacheTtl::default(),
1006 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1007 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1008 last_msg_id: Arc::new(Mutex::new(None)),
1009 session_manager,
1010 hook_bus: host.hook_bus,
1011 reaper_handle,
1012 reaper_cancel,
1013 credential_source: host.credential_source,
1014 token_cache: host.token_cache,
1015 trusted_worker_models: Vec::new(),
1016 progressive_tool_disclosure: host.progressive_tool_disclosure,
1017 activation_confirm: agent_core::config::ActivationConfirm::default(),
1018 delegation_parent: None,
1019 mcp_runtime: None,
1020 mcp_session_scope: None,
1021 extension_runtime: None,
1022 extension_session_scope: None,
1023 turn_budget: crate::runtime::budget::TurnBudget::for_role(
1024 crate::runtime::budget::TurnRole::Foreground,
1025 ),
1026 host_tool_session: fresh_host_tool_session(),
1027 session_id: None,
1028 cwd: None,
1029 env: None,
1030 env_stripped: Vec::new(),
1031 env_warned: Default::default(),
1032 };
1033 if let Some(m) = mcp_runtime {
1037 runtime.install_mcp_runtime(m);
1038 }
1039 if let Some(m) = extension_runtime {
1040 runtime.install_extension_runtime(m);
1041 }
1042 runtime
1043 }
1044
1045
1046 #[cfg(any(test, feature = "testing"))]
1057 pub fn new_headless() -> Self {
1058 let client = build_http_client(HTTP_READ_TIMEOUT)
1059 .expect("reqwest client construction is infallible with built-in roots");
1060 let host = crate::host::HostParts {
1061 client,
1062 tools: Arc::new(RwLock::new(ToolRegistry::new())),
1063 hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
1064 credential_source: crate::auth::CredentialSource::Local,
1065 token_cache: crate::auth::TokenCache::new(),
1066 mcp_runtime: None,
1067 extension_runtime: None,
1068 capture_dir: trace::default_capture_dir(),
1069 progressive_tool_disclosure: false,
1070 };
1071 let mut runtime = Self::from_parts(RuntimeParts::without_reaper(host));
1072 runtime.auth = Arc::new(RwLock::new(AuthState {
1073 auth_token: "test-token".to_string(),
1074 auth_type: "api_key".to_string(),
1075 refresh_token: None,
1076 token_expires: None,
1077 }));
1078 runtime
1079 }
1080
1081 pub fn set_system_prompt(&mut self, prompt: String) {
1082 self.system_prompt = Some(prompt);
1083 }
1084
1085 pub fn system_prompt(&self) -> Option<&str> {
1086 self.system_prompt.as_deref()
1087 }
1088
1089 pub async fn effective_system_prompt(&self) -> Option<String> {
1101 if self.effective_prompt.is_some() {
1102 return self.system_prompt.clone();
1103 }
1104 if self.tools.read().await.get("subagent_start").is_none() {
1105 return self.system_prompt.clone();
1106 }
1107 let Ok(model) = crate::orchestration::canonical_foreground_identity(&self.model) else {
1108 return self.system_prompt.clone();
1109 };
1110 let workflow_mode = match self.reasoning_level() {
1111 agent_core::reasoning::ReasoningLevel::UltraCode => {
1112 Some(agent_core::prompt::WorkflowMode::UltraCode)
1113 }
1114 agent_core::reasoning::ReasoningLevel::Max => {
1115 Some(agent_core::prompt::WorkflowMode::Max)
1116 }
1117 agent_core::reasoning::ReasoningLevel::XHigh => {
1118 Some(agent_core::prompt::WorkflowMode::XHigh)
1119 }
1120 _ => None,
1121 };
1122 let Ok(context) = agent_core::prompt::SelectionContext::new(model, None)
1123 .map(|context| context.with_workflow_mode(workflow_mode))
1124 else {
1125 return self.system_prompt.clone();
1126 };
1127 agent_core::prompt::compose_orchestration_prompt(self.system_prompt.as_deref(), &context)
1128 }
1129
1130 pub fn effective_prompt(&self) -> Option<&agent_core::prompt::PromptStack> {
1131 self.effective_prompt.as_ref()
1132 }
1133
1134 pub fn prompt_generation(&self) -> u64 {
1135 self.prompt_generation
1136 }
1137
1138 pub fn retain_prompt_reload_source(
1139 &mut self,
1140 manifest: PathBuf,
1141 context: agent_core::prompt::SelectionContext,
1142 user_module: Option<agent_core::prompt::PromptModule>,
1143 delegation_policy_digest: Option<String>,
1144 ) {
1145 self.prompt_reload_source = Some(PromptReloadSource {
1146 manifest,
1147 context,
1148 user_module,
1149 delegation_policy_digest,
1150 });
1151 }
1152
1153 pub fn reload_prompt(&mut self) -> std::result::Result<u64, agent_core::prompt::PromptError> {
1155 let source = self.prompt_reload_source.clone().ok_or_else(|| {
1156 agent_core::prompt::PromptError::Invalid("no prompt manifest is active".into())
1157 })?;
1158 let raw = std::fs::read_to_string(&source.manifest).map_err(|_| {
1159 agent_core::prompt::PromptError::Invalid("prompt manifest is unavailable".into())
1160 })?;
1161 let manifest = agent_core::prompt::PromptManifest::parse(&raw)?;
1162 let reload_catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
1163 source.context.model(),
1164 manifest.delegation_catalog_candidates(),
1165 )
1166 .map_err(|error| agent_core::prompt::PromptError::Invalid(error.into()))?;
1167 let candidate_policy_digest = manifest
1168 .delegation_policy(source.context.model().clone(), &reload_catalog)?
1169 .map(|policy| policy.digest());
1170 if candidate_policy_digest != source.delegation_policy_digest {
1171 return Err(agent_core::prompt::PromptError::Invalid(
1172 "hot reload cannot safely change delegation policy".into(),
1173 ));
1174 }
1175 let registry = manifest.registry(source.manifest.parent())?;
1176 let candidate = agent_core::prompt::compile_prompt_stack(
1177 &manifest,
1178 ®istry,
1179 &source.context,
1180 source.user_module,
1181 )?;
1182 self.apply_prompt_stack(candidate)
1183 }
1184
1185 pub fn apply_prompt_stack(
1187 &mut self,
1188 candidate: agent_core::prompt::PromptStack,
1189 ) -> std::result::Result<u64, agent_core::prompt::PromptError> {
1190 if let Some(current) = &self.effective_prompt {
1191 current.validate_hot_reload(&candidate)?;
1192 }
1193 let composed = candidate.composed().to_owned();
1194 self.effective_prompt = Some(candidate);
1195 self.system_prompt = Some(composed);
1196 self.prompt_generation = self.prompt_generation.saturating_add(1);
1197 Ok(self.prompt_generation)
1198 }
1199
1200 pub fn prompt_inspection_json(&self) -> Option<String> {
1201 let mode = self
1202 .orchestration
1203 .as_ref()
1204 .map(|runtime| runtime.enforcement_mode())
1205 .unwrap_or(agent_core::orchestration::EnforcementMode::Off);
1206 self.effective_prompt.as_ref().and_then(|stack| {
1207 serde_json::to_string(&serde_json::json!({
1208 "generation": self.prompt_generation,
1209 "effective": stack.inspect(mode),
1210 "token_estimate": stack.composed().len().div_ceil(4),
1211 }))
1212 .ok()
1213 })
1214 }
1215
1216 pub fn install_mcp_runtime(&mut self, manager: std::sync::Arc<crate::mcp::McpRuntimeManager>) {
1221 self.mcp_session_scope = Some(std::sync::Arc::new(crate::mcp::McpSessionEndGuard::new(
1222 self.host_tool_session.clone(),
1223 std::sync::Arc::clone(&manager),
1224 )));
1225 self.mcp_runtime = Some(manager);
1226 }
1227
1228 pub fn set_turn_budget(&mut self, budget: crate::runtime::budget::TurnBudget) {
1232 self.turn_budget = budget;
1233 }
1234
1235 pub fn turn_budget(&self) -> &crate::runtime::budget::TurnBudget {
1237 &self.turn_budget
1238 }
1239
1240 pub fn install_extension_runtime(
1245 &mut self,
1246 manager: std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
1247 ) {
1248 manager.bind_host_scope(self.host_tool_session.clone());
1252 self.extension_session_scope = Some(std::sync::Arc::new(
1253 crate::extensions::lease::ExtensionSessionEndGuard::new(
1254 self.host_tool_session.clone(),
1255 std::sync::Arc::clone(&manager),
1256 ),
1257 ));
1258 self.extension_runtime = Some(manager);
1259 }
1260
1261 pub fn install_orchestration(
1262 &mut self,
1263 runtime: Arc<crate::orchestration::OrchestrationRuntime>,
1264 ) {
1265 for model in &self.trusted_worker_models {
1266 if let Err(error) = runtime.grant_worker_model(model.as_str()) {
1267 tracing::warn!(
1268 model = model.as_str(),
1269 error = %error,
1270 "failed to apply configured worker-model trust"
1271 );
1272 }
1273 }
1274 self.orchestration = Some(runtime);
1275 }
1276
1277 pub fn install_worker_orchestration(
1283 &mut self,
1284 runtime: Arc<crate::orchestration::OrchestrationRuntime>,
1285 ) {
1286 self.orchestration = Some(runtime);
1287 }
1288
1289 pub fn set_delegation_parent(&mut self, parent: Option<String>) {
1290 self.delegation_parent = parent;
1291 }
1292
1293 pub fn orchestration(&self) -> Option<&Arc<crate::orchestration::OrchestrationRuntime>> {
1294 self.orchestration.as_ref()
1295 }
1296
1297 pub fn grant_worker_model(&self, model: &str) -> std::result::Result<(), String> {
1302 self.orchestration
1303 .as_ref()
1304 .ok_or_else(|| "delegation policy unavailable".to_string())?
1305 .grant_worker_model(model)
1306 }
1307
1308 pub fn set_model(&mut self, model: String) -> Option<ReasoningClamp> {
1312 self.try_set_model(model).ok().flatten()
1313 }
1314
1315 pub fn try_set_model(
1322 &mut self,
1323 model: String,
1324 ) -> std::result::Result<Option<ReasoningClamp>, String> {
1325 let trimmed = model.trim();
1330 let cleaned = trimmed
1331 .split_once(char::is_whitespace)
1332 .and_then(|(_, rest)| {
1333 let rest = rest.trim_start();
1334 let (latency, candidate) = rest.split_once(char::is_whitespace)?;
1335 let millis = latency.strip_suffix("ms")?;
1336 if !millis.is_empty() && millis.chars().all(|c| c.is_ascii_digit()) {
1337 let candidate = candidate.trim();
1338 if !candidate.is_empty() && !candidate.chars().any(char::is_whitespace) {
1339 return Some(candidate);
1340 }
1341 }
1342 None
1343 })
1344 .unwrap_or(trimmed);
1345 if self.prompt_reload_source.is_none()
1351 && self
1352 .orchestration
1353 .as_ref()
1354 .is_some_and(|current| !current.unreconciled_runtime_handles().is_empty())
1355 {
1356 return Err("model change blocked: workers require collection/reconciliation".into());
1357 }
1358 let orchestration_replacement = if self.prompt_reload_source.is_none()
1359 && self.orchestration.is_some()
1360 {
1361 let foreground = crate::orchestration::canonical_foreground_identity(cleaned)
1362 .map_err(|_| "model change blocked: unresolved foreground model".to_string())?;
1363 let replacement = crate::orchestration::OrchestrationRuntime::baseline(
1364 foreground, 8, 64,
1365 )
1366 .map_err(|_| "model change blocked: trusted worker catalog unavailable".to_string())?;
1367 for trusted in &self.trusted_worker_models {
1368 replacement
1369 .grant_worker_model(trusted.as_str())
1370 .map_err(|_| {
1371 "model change blocked: configured worker trust invalid".to_string()
1372 })?;
1373 }
1374 Some(Arc::new(replacement))
1375 } else {
1376 None
1377 };
1378 self.model = cleaned.to_owned();
1379 if let Some(replacement) = orchestration_replacement {
1383 self.orchestration = Some(replacement);
1384 }
1385 if !self.explicit_reasoning {
1391 if let Some(level) =
1392 crate::runtime::openai::catalog::validation::default_level_for_model(cleaned)
1393 {
1394 self.set_reasoning_level(level);
1395 }
1396 }
1397 Ok(self.clamp_reasoning_to_model())
1402 }
1403
1404 fn clamp_reasoning_to_model(&mut self) -> Option<ReasoningClamp> {
1410 use crate::runtime::openai::catalog::validation::{
1411 default_level_for_model, thinking_options_for_model, validate_reasoning_mutation,
1412 };
1413 let model = self.model.as_str();
1414 let from = self.reasoning_level();
1415 if validate_reasoning_mutation(model, from).is_ok() {
1416 return None;
1417 }
1418 let to = default_level_for_model(model)
1419 .filter(|level| validate_reasoning_mutation(model, *level).is_ok())
1420 .or_else(|| {
1421 thinking_options_for_model(model)
1422 .iter()
1423 .filter_map(|opt| agent_core::reasoning::ReasoningLevel::parse(opt))
1424 .find(|level| validate_reasoning_mutation(model, *level).is_ok())
1425 })?;
1426 let explicit = self.explicit_reasoning;
1427 self.set_reasoning_level(to);
1428 self.explicit_reasoning = explicit;
1429 Some(ReasoningClamp { from, to })
1430 }
1431
1432 pub fn set_tools(&mut self, tools: ToolRegistry) {
1433 self.tools = Arc::new(RwLock::new(tools));
1434 }
1435
1436 pub fn subagent_registry(&self) -> &Arc<Mutex<crate::runtime::subagent::SubagentRegistry>> {
1437 &self.subagent_registry
1438 }
1439
1440 pub fn event_queue(&self) -> &Arc<crate::events::EventQueue> {
1441 &self.event_queue
1442 }
1443
1444 pub fn set_event_queue(&mut self, queue: Arc<crate::events::EventQueue>) {
1448 self.event_queue = queue;
1449 }
1450
1451 pub fn hook_bus(&self) -> &Arc<crate::extensions::hooks::HookBus> {
1453 &self.hook_bus
1454 }
1455
1456 pub fn session_manager(&self) -> &Arc<crate::tools::shell::SessionManager> {
1459 &self.session_manager
1460 }
1461
1462 pub fn host_tool_session_id(&self) -> &crate::tools::activation::SessionId {
1466 &self.host_tool_session
1467 }
1468
1469 pub fn set_session_id(&mut self, id: Option<String>) {
1472 self.session_id = id;
1473 }
1474
1475 pub fn session_id(&self) -> Option<&str> {
1476 self.session_id.as_deref()
1477 }
1478
1479 pub fn set_cwd(&mut self, cwd: Option<PathBuf>) {
1482 self.cwd = cwd;
1483 }
1484
1485 pub fn cwd(&self) -> Option<&Path> {
1486 self.cwd.as_deref()
1487 }
1488
1489 pub fn set_env(&mut self, env: Option<crate::session::types::SessionEnv>) {
1492 self.env = env;
1493 }
1494
1495 pub fn set_env_stripped(&mut self, stripped: Vec<String>) {
1496 self.env_stripped = stripped;
1497 }
1498
1499 pub fn env(&self) -> Option<&crate::session::types::SessionEnv> {
1500 self.env.as_ref()
1501 }
1502
1503 pub fn env_stripped(&self) -> &[String] {
1504 &self.env_stripped
1505 }
1506
1507 pub fn tools_shared(&self) -> Arc<RwLock<ToolRegistry>> {
1509 Arc::clone(&self.tools)
1510 }
1511
1512 pub fn model(&self) -> &str {
1513 &self.model
1514 }
1515
1516 pub fn http_client(&self) -> &Client {
1517 &self.client
1518 }
1519
1520 pub fn progressive_tool_disclosure(&self) -> bool {
1522 self.progressive_tool_disclosure
1523 }
1524
1525 pub fn activation_confirm(&self) -> agent_core::config::ActivationConfirm {
1527 self.activation_confirm
1528 }
1529
1530 pub fn set_activation_confirm(&mut self, mode: agent_core::config::ActivationConfirm) {
1533 self.activation_confirm = mode;
1534 }
1535
1536 #[cfg(any(test, feature = "testing"))]
1540 pub fn http_client_pool_id(&self) -> usize {
1541 const _: () = assert!(std::mem::size_of::<Client>() == std::mem::size_of::<usize>());
1542 unsafe { std::mem::transmute_copy::<Client, usize>(&self.client) }
1545 }
1546 pub fn set_thinking_budget(&mut self, budget: u32) {
1547 self.thinking_budget = budget;
1548 self.named_level = Some(agent_core::reasoning::ReasoningLevel::from_legacy_budget(
1550 budget,
1551 ));
1552 self.explicit_reasoning = false;
1554 }
1555
1556 pub fn set_reasoning_level(&mut self, level: agent_core::reasoning::ReasoningLevel) {
1562 self.named_level = Some(level);
1563 if let Some(budget) = level.to_legacy_budget() {
1564 self.thinking_budget = budget;
1565 }
1566 self.explicit_reasoning = false;
1568 }
1569
1570 pub fn set_reasoning_level_explicit(&mut self, level: agent_core::reasoning::ReasoningLevel) {
1575 self.set_reasoning_level(level);
1576 self.explicit_reasoning = true;
1577 }
1578
1579 pub fn restore_session_reasoning(&mut self, thinking_level: &str) -> Option<ReasoningClamp> {
1585 if let Some(level) = agent_core::reasoning::ReasoningLevel::parse(thinking_level) {
1586 self.set_reasoning_level_explicit(level);
1587 } else if let Some(budget) = crate::models::budget_for_thinking_level(thinking_level) {
1588 self.set_thinking_budget_explicit(budget);
1589 } else {
1590 return None;
1591 }
1592 self.clamp_reasoning_to_model()
1593 }
1594
1595 pub fn set_thinking_budget_explicit(&mut self, budget: u32) {
1600 self.thinking_budget = budget;
1601 self.named_level = Some(agent_core::reasoning::ReasoningLevel::from_legacy_budget(
1602 budget,
1603 ));
1604 self.explicit_reasoning = true;
1605 }
1606
1607 pub fn is_reasoning_explicit(&self) -> bool {
1609 self.explicit_reasoning
1610 }
1611
1612 pub fn thinking_budget_raw(&self) -> u32 {
1614 self.thinking_budget
1615 }
1616
1617 pub fn set_reasoning_level_checked(
1622 &mut self,
1623 level: agent_core::reasoning::ReasoningLevel,
1624 ) -> std::result::Result<(), String> {
1625 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
1626 &self.model,
1627 level,
1628 )?;
1629 if self.model.starts_with("anthropic/")
1630 && level == agent_core::reasoning::ReasoningLevel::UltraCode
1631 {
1632 if self.codex_request_role()
1633 != crate::runtime::openai::catalog::ExecutionRole::Foreground
1634 {
1635 return Err("ultracode_requires_foreground".into());
1636 }
1637 if self.effective_prompt.is_some() {
1638 return Err("typed_prompt_manifest_blocks_required_doctrine".into());
1639 }
1640 if self.orchestration.is_none() {
1641 return Err("ultracode_requires_orchestration".into());
1642 }
1643 let tools = self.tools.try_read().map_err(|_| {
1644 "ultracode prerequisite state is busy; refusing mutation".to_string()
1645 })?;
1646 let required = ["subagent_start", "subagent_status", "subagent_collect"];
1647 if !required.iter().all(|name| {
1648 tools
1649 .get(name)
1650 .is_some_and(|tool| tool.extension_id().is_none())
1651 }) {
1652 return Err("ultracode_requires_lifecycle_tools".into());
1653 }
1654 }
1655 self.set_reasoning_level_explicit(level);
1656 Ok(())
1657 }
1658
1659 pub fn set_thinking_budget_checked(&mut self, budget: u32) -> std::result::Result<(), String> {
1666 let level = agent_core::reasoning::ReasoningLevel::from_legacy_budget(budget);
1667 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
1668 &self.model,
1669 level,
1670 )?;
1671 self.set_thinking_budget_explicit(budget);
1672 Ok(())
1673 }
1674
1675 pub fn reasoning_level(&self) -> agent_core::reasoning::ReasoningLevel {
1676 self.named_level.unwrap_or_else(|| {
1677 agent_core::reasoning::ReasoningLevel::from_legacy_budget(self.thinking_budget)
1678 })
1679 }
1680
1681 pub(crate) fn set_codex_request_role(
1682 &mut self,
1683 role: crate::runtime::openai::catalog::CodexRequestRole,
1684 ) {
1685 self.codex_request_role = role;
1686 }
1687
1688 pub(crate) fn codex_request_role(&self) -> crate::runtime::openai::catalog::CodexRequestRole {
1689 self.codex_request_role
1690 }
1691
1692 pub(crate) fn codex_delegation_plan(
1693 model: &str,
1694 level: agent_core::reasoning::ReasoningLevel,
1695 role: crate::runtime::openai::catalog::CodexRequestRole,
1696 ) -> Option<crate::runtime::openai::catalog::CodexExecutionPlan> {
1697 use crate::runtime::openai::catalog::{plan_codex_execution, CodexRequestRole};
1698 if role != CodexRequestRole::Foreground
1699 || level != agent_core::reasoning::ReasoningLevel::Ultra
1700 || !model.starts_with("openai-codex/")
1701 {
1702 return None;
1703 }
1704 plan_codex_execution(model, level, role, None).ok()
1705 }
1706
1707 async fn authorized_anthropic_plan(
1708 &self,
1709 ) -> Result<Option<crate::runtime::openai::catalog::AnthropicExecutionPlan>> {
1710 if !self.model.starts_with("anthropic/") {
1711 return Ok(None);
1712 }
1713 let tools = self.tools.read().await;
1714 let builtin = |name: &str| {
1715 tools
1716 .get(name)
1717 .is_some_and(|tool| tool.extension_id().is_none())
1718 };
1719 let readiness = self
1720 .orchestration
1721 .as_ref()
1722 .and_then(|runtime| runtime.ultracode_readiness(&self.model).ok());
1723 let prerequisites = crate::runtime::openai::catalog::AnthropicPlanPrerequisites {
1724 orchestration_policy: self.orchestration.is_some(),
1725 foreground_worker_authorized: readiness.is_some(),
1726 concurrent_limit: readiness.map_or(0, |limits| limits.0),
1727 total_limit: readiness.map_or(0, |limits| limits.1),
1728 lifecycle_start: builtin("subagent_start"),
1729 lifecycle_status: builtin("subagent_status"),
1730 lifecycle_steer: builtin("subagent_steer"),
1731 lifecycle_collect: builtin("subagent_collect"),
1732 lifecycle_resume: builtin("subagent_resume"),
1733 };
1734 crate::runtime::openai::catalog::plan_anthropic_execution(
1735 &self.model,
1736 self.reasoning_level(),
1737 self.codex_request_role(),
1738 prerequisites,
1739 crate::runtime::openai::catalog::capability_cache::get(&self.model).and_then(|entry| {
1740 match entry.reasoning {
1741 crate::runtime::openai::catalog::ReasoningSupport::AnthropicAdaptive {
1742 adaptive,
1743 } => Some(adaptive),
1744 crate::runtime::openai::catalog::ReasoningSupport::None => Some(false),
1745 _ => None,
1746 }
1747 }),
1748 )
1749 .map(Some)
1750 .map_err(|error| RuntimeError::Config(error.to_string()))
1751 }
1752
1753 async fn validate_request_preflight(&self) -> Result<()> {
1756 self.validate_request_preflight_for(&self.model, self.codex_request_role)
1757 .await
1758 }
1759
1760 pub(crate) async fn validate_session_driver_preflight(&self) -> Result<()> {
1763 self.validate_request_preflight().await
1764 }
1765
1766 async fn validate_request_preflight_for(
1767 &self,
1768 model: &str,
1769 role: crate::runtime::openai::catalog::CodexRequestRole,
1770 ) -> Result<()> {
1771 if self
1775 .memory_backend_reconfigure_denied
1776 .load(std::sync::atomic::Ordering::SeqCst)
1777 {
1778 return Err(RuntimeError::Config(
1779 "memory backend cannot be reconfigured at runtime (including live legacy extension processes); restart required; request denied".into(),
1780 ));
1781 }
1782 let level = self.reasoning_level();
1783 if model.starts_with("anthropic/")
1784 && level == agent_core::reasoning::ReasoningLevel::UltraCode
1785 && self.effective_prompt.is_some()
1786 {
1787 return Err(RuntimeError::Config(
1788 "Anthropic execution plan rejected: typed_prompt_manifest_blocks_required_doctrine"
1789 .into(),
1790 ));
1791 }
1792 if model.starts_with("anthropic/")
1793 && matches!(
1794 level,
1795 agent_core::reasoning::ReasoningLevel::Max
1796 | agent_core::reasoning::ReasoningLevel::UltraCode
1797 | agent_core::reasoning::ReasoningLevel::Ultra
1798 )
1799 {
1800 let tools = self.tools.read().await;
1801 let builtin = |name: &str| {
1802 tools
1803 .get(name)
1804 .is_some_and(|tool| tool.extension_id().is_none())
1805 };
1806 let readiness = self
1807 .orchestration
1808 .as_ref()
1809 .and_then(|runtime| runtime.ultracode_readiness(model).ok());
1810 let prerequisites = crate::runtime::openai::catalog::AnthropicPlanPrerequisites {
1811 orchestration_policy: self.orchestration.is_some(),
1812 foreground_worker_authorized: readiness.is_some(),
1813 concurrent_limit: readiness.map_or(0, |limits| limits.0),
1814 total_limit: readiness.map_or(0, |limits| limits.1),
1815 lifecycle_start: builtin("subagent_start"),
1816 lifecycle_status: builtin("subagent_status"),
1817 lifecycle_steer: builtin("subagent_steer"),
1818 lifecycle_collect: builtin("subagent_collect"),
1819 lifecycle_resume: builtin("subagent_resume"),
1820 };
1821 drop(tools);
1822 let result = crate::runtime::openai::catalog::plan_anthropic_execution(
1823 model,
1824 level,
1825 role,
1826 prerequisites,
1827 crate::runtime::openai::catalog::capability_cache::get(model).and_then(|entry| {
1828 match entry.reasoning {
1829 crate::runtime::openai::catalog::ReasoningSupport::AnthropicAdaptive {
1830 adaptive,
1831 } => Some(adaptive),
1832 crate::runtime::openai::catalog::ReasoningSupport::None => Some(false),
1833 _ => None,
1834 }
1835 }),
1836 );
1837 match result {
1838 Ok(plan) => {
1839 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);
1840 return Ok(());
1841 }
1842 Err(error) => {
1843 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);
1844 return Err(RuntimeError::Config(error.to_string()));
1845 }
1846 }
1847 }
1848 if model.starts_with("openai-codex/") {
1849 let plan =
1850 crate::runtime::openai::catalog::plan_codex_execution(model, level, role, None)
1851 .map_err(|error| {
1852 tracing::debug!(
1853 event = "codex_mode_plan",
1854 qualified_model = %model,
1855 requested_level = %level,
1856 runtime_role = role.as_str(),
1857 decision = "deny",
1858 deny_code = error.code().as_str(),
1859 network_attempted = false,
1860 "Codex request preflight denied"
1861 );
1862 RuntimeError::Config(error.to_string())
1863 })?;
1864
1865 if plan.automatic_delegation() {
1866 if self.orchestration.is_none() {
1867 tracing::debug!(
1868 event = "codex_mode_plan",
1869 qualified_model = %model,
1870 requested_level = %level,
1871 runtime_role = role.as_str(),
1872 decision = "deny",
1873 deny_code = "ultra_requires_orchestration",
1874 network_attempted = false,
1875 "Codex request preflight denied"
1876 );
1877 return Err(RuntimeError::Config(
1878 "Ultra requires an installed orchestration policy".to_string(),
1879 ));
1880 }
1881
1882 let tools = self.tools.read().await;
1883 let required = ["subagent_start", "subagent_status", "subagent_collect"];
1884 let tools_ready = required.iter().all(|name| {
1885 tools
1886 .get(name)
1887 .is_some_and(|tool| tool.extension_id().is_none())
1888 });
1889 if !tools_ready {
1890 tracing::debug!(
1891 event = "codex_mode_plan",
1892 qualified_model = %model,
1893 requested_level = %level,
1894 runtime_role = role.as_str(),
1895 decision = "deny",
1896 deny_code = "ultra_requires_subagent_tools",
1897 network_attempted = false,
1898 "Codex request preflight denied"
1899 );
1900 return Err(RuntimeError::Config(
1901 "Ultra requires built-in subagent_start, subagent_status, and subagent_collect tools"
1902 .to_string(),
1903 ));
1904 }
1905 }
1906 return Ok(());
1907 }
1908
1909 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(model, level)
1910 .map_err(|error| {
1911 tracing::debug!(
1912 event = "codex_mode_plan",
1913 qualified_model = %model,
1914 requested_level = %level,
1915 runtime_role = role.as_str(),
1916 decision = "deny",
1917 deny_code = "unsupported_reasoning_level",
1918 network_attempted = false,
1919 "request preflight denied"
1920 );
1921 RuntimeError::Config(error)
1922 })
1923 }
1924
1925 pub fn set_compaction_model(&mut self, model: Option<String>) {
1926 self.compaction_model = model;
1927 }
1928
1929 pub fn set_compaction_mode(&mut self, mode: agent_core::compaction::CompactionMode) {
1931 self.compaction_mode = mode;
1932 }
1933
1934 pub fn set_compaction_exclusions(
1936 &mut self,
1937 exclude: Vec<agent_core::compaction::ContentClass>,
1938 ) {
1939 self.compaction_exclusions = exclude;
1940 }
1941
1942 pub fn remote_summarization_attempts(&self) -> u64 {
1945 self.remote_summarization_attempts
1946 .load(std::sync::atomic::Ordering::SeqCst)
1947 }
1948
1949 pub fn compaction_policy(&self) -> crate::runtime::compaction::DisclosurePolicy {
1952 crate::runtime::compaction::DisclosurePolicy {
1953 mode: self.compaction_mode,
1954 exclude: self.compaction_exclusions.clone(),
1955 }
1956 }
1957
1958 pub fn set_context_window(&mut self, window: Option<u64>) {
1959 self.context_window_override = window;
1960 }
1961
1962 pub fn compaction_model(&self) -> &str {
1965 self.compaction_model
1966 .as_deref()
1967 .unwrap_or("claude-sonnet-4-6")
1968 }
1969
1970 pub fn context_window(&self) -> u64 {
1971 self.context_window_override
1972 .unwrap_or_else(|| crate::models::context_window_for_model(&self.model))
1973 }
1974
1975 pub async fn assess_context(
1989 &self,
1990 messages: &[crate::SharedMessage],
1991 ) -> context::ContextAssessment {
1992 let system = self.effective_system_prompt().await;
1993 let schema = self.tools.read().await.tools_schema();
1994 let memory_rendered: Option<String> = self
1999 .pending_memory_segment
2000 .lock()
2001 .unwrap_or_else(std::sync::PoisonError::into_inner)
2002 .as_ref()
2003 .map(|segment| segment.rendered_text().to_string());
2004 let memory_contents: Vec<&str> = memory_rendered.as_deref().into_iter().collect();
2005 context::assess(&context::ContextBudgetInputs {
2006 model: &self.model,
2007 provider_window: self.context_window(),
2008 system_prompt: system.as_deref(),
2009 tools_schema: &schema,
2010 messages,
2011 skill_contents: &[],
2012 memory_contents: &memory_contents,
2013 thinking_budget_tokens: self.thinking_budget as u64,
2014 next_tool_result_bytes: self.max_tool_output as u64,
2015 output_reserve_tokens: HelperMethods::max_tokens_for_model(&self.model),
2016 })
2017 }
2018
2019 #[allow(dead_code)] pub(crate) fn hold_memory_contribution(
2037 &self,
2038 contribution: memory_context::MemoryContextContribution,
2039 ) -> std::result::Result<(), memory_context::MemoryContextError> {
2040 let budget = memory_context::memory_budget_tokens(self.context_window())
2041 .ok_or(memory_context::MemoryContextError::RecallBudgetBelowMinimum)?;
2042 memory_context::validate_contribution(
2046 &contribution,
2047 &self.memory_context_project_id(),
2048 budget,
2049 &memory_context::DisclosureGrantSet::model_visible_only(),
2050 )?;
2051 *self
2052 .pending_memory_segment
2053 .lock()
2054 .unwrap_or_else(std::sync::PoisonError::into_inner) =
2055 Some(memory_context::ContextSegment::Memory(contribution));
2056 Ok(())
2057 }
2058
2059 pub(crate) fn clear_memory_contribution(&self) {
2063 *self
2064 .pending_memory_segment
2065 .lock()
2066 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
2067 }
2068
2069 pub fn memory_recall_why(&self) -> Option<memory_context::RecallTurnMetadata> {
2075 self.retained_recall_turn
2076 .lock()
2077 .unwrap_or_else(std::sync::PoisonError::into_inner)
2078 .as_ref()
2079 .map(|retained| retained.why.clone())
2080 }
2081
2082 async fn apply_turn_memory_recall(&self, messages: &mut Vec<crate::SharedMessage>) {
2100 if (self.memory_backend.exclusive() && !self.memory_backend.is_axel())
2101 || self
2102 .memory_backend_reconfigure_denied
2103 .load(std::sync::atomic::Ordering::SeqCst)
2104 {
2105 self.clear_memory_contribution();
2106 tracing::debug!("memory recall unavailable for the selected host backend");
2107 return;
2108 }
2109 let extension_runtime = self.extension_runtime.clone();
2110 let binding = self.memory_backend.clone();
2111 let session = self.host_tool_session.clone();
2112 let outcome = memory_context::resolve_turn_recall(
2113 &self.memory_context_state,
2114 &self.retained_recall_turn,
2115 &self.memory_context_project_id(),
2116 self.context_window(),
2117 messages,
2118 memory_context::RECALL_HARD_TIMEOUT,
2119 move |lease: memory_context::MemoryContextLease,
2120 request: memory_context::RecallRequest| async move {
2121 if binding.is_axel() {
2122 return axel_context::recall(binding, lease, request).await;
2123 }
2124 let Some(manager) = extension_runtime else {
2125 return Err(memory_context::RecallCallError::ProviderUnavailable);
2126 };
2127 let mut parts = lease.provider_id.as_str().splitn(3, ':');
2128 let (Some("extension"), Some(plugin), Some(local)) =
2129 (parts.next(), parts.next(), parts.next())
2130 else {
2131 return Err(memory_context::RecallCallError::ProviderUnavailable);
2132 };
2133 let Some(digest) =
2134 manager.declared_tool_digest(plugin, memory_context::MEMORY_RECALL_TOOL_NAME)
2135 else {
2136 return Err(memory_context::RecallCallError::ProviderUnavailable);
2137 };
2138 let mut response =
2139 crate::extensions::lease::ExtensionLeaseCapability::new(session, manager)
2140 .call_exact(
2141 plugin,
2142 memory_context::MEMORY_RECALL_TOOL_NAME,
2143 &digest,
2144 memory_context::recall_request_wire(&request),
2145 )
2146 .await
2147 .map_err(|_| memory_context::RecallCallError::CallFailed)?;
2148 if response["provider_id"] != local
2151 && response["provider_id"] != lease.provider_id.as_str()
2152 {
2153 return Err(memory_context::RecallCallError::CallFailed);
2154 }
2155 response["provider_id"] = json!(lease.provider_id.as_str());
2156 Ok(response)
2157 },
2158 )
2159 .await;
2160 match outcome {
2161 memory_context::TurnRecallOutcome::Injected
2162 | memory_context::TurnRecallOutcome::ReusedRetained => {
2163 let contribution = self
2164 .retained_recall_turn
2165 .lock()
2166 .unwrap_or_else(std::sync::PoisonError::into_inner)
2167 .as_ref()
2168 .map(|retained| retained.contribution.clone());
2169 if let Some(contribution) = contribution {
2170 *self
2171 .pending_memory_segment
2172 .lock()
2173 .unwrap_or_else(std::sync::PoisonError::into_inner) =
2174 Some(memory_context::ContextSegment::Memory(contribution));
2175 }
2176 }
2177 memory_context::TurnRecallOutcome::NotEligible => {
2178 self.clear_memory_contribution();
2179 }
2180 memory_context::TurnRecallOutcome::SkippedOpen(reason) => {
2181 self.clear_memory_contribution();
2182 tracing::debug!(
2185 reason = reason.as_str(),
2186 "memory recall skipped; turn proceeds without memory"
2187 );
2188 }
2189 }
2190 }
2191
2192 #[cfg(any(test, feature = "testing"))]
2202 pub async fn apply_turn_memory_recall_for_harness(
2203 &self,
2204 messages: &mut Vec<crate::SharedMessage>,
2205 ) {
2206 self.apply_turn_memory_recall(messages).await;
2207 }
2208
2209 #[cfg(any(test, feature = "testing"))]
2213 pub fn capture_completed_turn_for_harness(
2214 &self,
2215 messages: Vec<crate::SharedMessage>,
2216 turn_ordinal: u64,
2217 ) -> std::result::Result<(), &'static str> {
2218 let started_at = std::time::SystemTime::now();
2219 let lease = self
2220 .memory_context_lock()
2221 .capture_lease_at(started_at)
2222 .ok_or("capture lease unavailable")?;
2223 let provider = self
2224 .extension_capture_provider(&lease)
2225 .ok_or("capture provider unavailable")?;
2226 let mut history = terminal_capture_history(&lease, &messages, started_at);
2227 history.turn_ordinal = turn_ordinal;
2228 memory_capture_worker()
2229 .submit_terminal(
2230 &lease,
2231 history,
2232 memory_context::RetentionClass::Standard,
2233 provider,
2234 )
2235 .map_err(|_| "capture build failed")?;
2236 Ok(())
2237 }
2238
2239 #[cfg(any(test, feature = "testing"))]
2242 pub fn capture_compaction_summary_for_harness(
2243 &self,
2244 first_turn_ordinal: u64,
2245 last_turn_ordinal: u64,
2246 source_digest: &str,
2247 source_message_count: usize,
2248 summary_text: &str,
2249 ) -> std::result::Result<(), &'static str> {
2250 let summarized_at = std::time::SystemTime::now();
2251 let lease = self
2252 .memory_context_lock()
2253 .capture_lease_at(summarized_at)
2254 .ok_or("capture lease unavailable")?;
2255 let source_digest = chat_capture::MessageRangeDigest::from_hex(source_digest)
2256 .ok_or("invalid source digest")?;
2257 let provider = self
2258 .extension_capture_provider(&lease)
2259 .ok_or("capture provider unavailable")?;
2260 let source = chat_capture::CompactionSource {
2261 schema: chat_capture::CompactionSchemaVersion::V1,
2262 project_id: lease.project_id.clone(),
2263 source_session_id: lease.session_id.clone(),
2264 first_turn_ordinal,
2265 last_turn_ordinal,
2266 source_digest,
2267 summary_origin: chat_capture::CompactionSummaryOrigin::LocalOnly,
2268 prompt_stack_digest: chat_capture::PromptStackDigest::from_bytes([7; 32]),
2269 redaction: chat_capture::RedactionPolicy::HostRedacted,
2270 content_classes: vec![agent_core::compaction::ContentClass::UserText],
2271 summarized_at,
2272 };
2273 memory_capture_worker()
2274 .submit_summary(
2275 &lease,
2276 source,
2277 source_message_count,
2278 summary_text,
2279 agent_core::compaction::RedactionPolicy::PolicyExclusions,
2280 memory_context::RetentionClass::Standard,
2281 provider,
2282 )
2283 .map_err(|_| "summary capture build failed")?;
2284 Ok(())
2285 }
2286
2287 #[cfg(any(test, feature = "testing"))]
2290 pub fn cancel_memory_forwarding_for_harness(&self) {
2291 self.clear_memory_contribution();
2292 self.memory_context_disable();
2293 }
2294
2295 #[cfg(any(test, feature = "testing"))]
2297 pub fn extension_lease_count_for_harness(&self) -> usize {
2298 self.extension_runtime
2299 .as_ref()
2300 .map_or(0, |manager| manager.lease_count())
2301 }
2302
2303 pub(crate) fn inherit_memory_backend(&mut self, binding: crate::memory_backend::MemoryBinding) {
2307 self.memory_backend = binding.fork_for_worker();
2308 }
2309
2310 pub fn apply_memory_backend_config(&mut self, config: &crate::config::MemoryBackendConfig) {
2312 if let Some(selected) = &self.memory_backend_config {
2313 if selected != config {
2314 self.memory_backend_reconfigure_denied
2315 .store(true, std::sync::atomic::Ordering::SeqCst);
2316 tracing::warn!("memory backend change denied: live extension processes cannot be reconfigured at runtime; original binding retained; restart required");
2317 self.memory_context_disable();
2318 self.clear_memory_contribution();
2319 }
2320 return;
2321 }
2322 if config.kind != crate::config::MemoryBackendKind::Legacy {
2323 self.memory_context_disable();
2324 }
2325 self.memory_backend = crate::memory_backend::MemoryBinding::from_config_with_cwd(
2329 config,
2330 self.cwd.clone().or_else(|| std::env::current_dir().ok()),
2331 );
2332 self.memory_backend_config = Some(config.clone());
2333 }
2334
2335 #[cfg(test)]
2336 #[allow(dead_code)] pub(crate) fn memory_backend_for_test(&self) -> crate::memory_backend::MemoryBinding {
2338 self.memory_backend.clone()
2339 }
2340
2341 pub fn memory_backend_is_axel(&self) -> bool {
2346 self.memory_backend.is_axel()
2347 }
2348
2349 pub fn memory_backend_exclusive(&self) -> bool {
2350 self.memory_backend.exclusive()
2351 }
2352
2353 fn memory_context_project_id(&self) -> memory_context::ProjectId {
2354 if self.memory_backend.exclusive() {
2355 self.memory_backend
2356 .scope()
2357 .ok()
2358 .and_then(|scope| memory_context::ProjectId::parse(scope.key()).ok())
2359 .unwrap_or_else(|| {
2360 memory_context::ProjectId::parse("project-unresolved").expect("static id")
2361 })
2362 } else {
2363 memory_project_id()
2364 }
2365 }
2366
2367 pub fn memory_tool_capability(&self) -> Option<memory_context::MemoryContextCapability> {
2368 if !self.memory_backend.exclusive() {
2369 return None;
2370 }
2371 let runtime = self.clone();
2372 Some(memory_context::MemoryContextCapability::control_only(
2373 self.memory_context_state.clone(),
2374 self.memory_context_project_id(),
2375 memory_context::ContextProviderId::parse(axel_context::PROVIDER_ID).expect("static id"),
2376 Arc::new(move || runtime.memory_context_disable()),
2377 ))
2378 }
2379
2380 pub fn apply_config(&mut self, config: &crate::config::SynapsConfig) {
2384 self.apply_config_inner(config, true);
2385 }
2386
2387 pub(crate) fn apply_config_keep_tools(&mut self, config: &crate::config::SynapsConfig) {
2394 self.apply_config_inner(config, false);
2395 }
2396
2397 fn apply_config_inner(&mut self, config: &crate::config::SynapsConfig, disable_tools: bool) {
2398 self.apply_memory_backend_config(&config.memory_backend);
2400 if (self.memory_backend.exclusive() && !self.memory_backend.is_axel())
2401 || self
2402 .memory_backend_reconfigure_denied
2403 .load(std::sync::atomic::Ordering::SeqCst)
2404 {
2405 self.memory_context_disable();
2407 self.clear_memory_contribution();
2408 *self
2409 .retained_recall_turn
2410 .lock()
2411 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
2412 }
2413 if let Some(ref model) = config.model {
2414 self.set_model(model.clone());
2415 }
2416 if let Some(level) = config.thinking_level {
2419 self.set_reasoning_level_explicit(level);
2420 } else if let Some(budget) = config.thinking_budget {
2421 self.set_thinking_budget_explicit(budget);
2422 }
2423 if let Some(clamp) = self.clamp_reasoning_to_model() {
2426 tracing::warn!(
2427 from = clamp.from.as_str(),
2428 to = clamp.to.as_str(),
2429 model = %self.model,
2430 "configured reasoning level not supported by model; clamped"
2431 );
2432 }
2433 self.context_window_override = config.context_window;
2434 self.continuation
2435 .lock()
2436 .unwrap_or_else(std::sync::PoisonError::into_inner)
2437 .config = config.context_management;
2438 self.compaction_model = config.compaction_model.clone();
2439 self.compaction_mode = config.compaction_mode;
2440 self.compaction_exclusions = config.compaction_exclude.clone();
2441 self.max_tool_output = config.max_tool_output;
2442 self.bash_timeout = config.bash_timeout;
2443 self.bash_max_timeout = config.bash_max_timeout;
2444 self.subagent_timeout = config.subagent_timeout;
2445 self.api_retries = config.api_retries;
2446 self.refusal_retries = config.refusal_retries;
2447 self.telemetry_level =
2448 crate::runtime::telemetry::TelemetryLevel::from_str_key(&config.telemetry);
2449 self.sync_observability();
2450 self.cache_diagnostics = config.cache_diagnostics;
2451 self.cache_ttl = config.cache_ttl;
2452 self.progressive_tool_disclosure = config.progressive_tool_disclosure;
2453 self.activation_confirm = config.tools_activation_confirm;
2454 tracing::info!(
2455 mode = config.tools_activation_confirm.as_str(),
2456 auto_approve_confirms = config.server.auto_approve_confirms,
2457 "tools.activation_confirm: model-initiated activate_tools policy"
2458 );
2459 self.trusted_worker_models = config
2460 .favorite_models
2461 .iter()
2462 .filter_map(|model| {
2463 let canonical = canonical_trusted_worker_model(model);
2464 match agent_core::prompt::QualifiedModelId::parse(canonical) {
2465 Ok(model) => Some(model),
2466 Err(_) => {
2467 tracing::warn!(
2468 model = model.as_str(),
2469 "ignoring invalid favorite worker-model identity"
2470 );
2471 None
2472 }
2473 }
2474 })
2475 .collect();
2476 self.trusted_worker_models.sort();
2477 self.trusted_worker_models.dedup();
2478 if let Some(orchestration) = &self.orchestration {
2479 for model in &self.trusted_worker_models {
2480 if let Err(error) = orchestration.grant_worker_model(model.as_str()) {
2481 tracing::warn!(
2482 model = model.as_str(),
2483 error = %error,
2484 "failed to apply configured worker-model trust"
2485 );
2486 }
2487 }
2488 }
2489 self.apply_auth_config(config);
2490
2491 if disable_tools && !config.disabled_tools.is_empty() {
2495 if let Ok(mut reg) = self.tools.try_write() {
2496 reg.disable(&config.disabled_tools);
2497 }
2498 }
2499 }
2500
2501 pub fn apply_auth_config(&mut self, config: &crate::config::SynapsConfig) {
2509 let new_source = config.auth.credential_source();
2510 if new_source != self.credential_source {
2511 self.token_cache.invalidate("anthropic");
2512 }
2513 self.credential_source = new_source;
2514 if self.credential_source.is_remote() {
2515 if let Ok(mut auth) = self.auth.try_write() {
2516 AuthMethods::scrub_for_remote(&mut auth);
2517 }
2518 }
2519 crate::auth::set_global_broker(crate::auth::broker_from_source(
2524 &self.credential_source,
2525 &self.token_cache,
2526 self.client.clone(),
2527 ));
2528 }
2529
2530 pub fn thinking_budget(&self) -> u32 {
2531 self.thinking_budget
2532 }
2533
2534 pub fn max_tool_output(&self) -> usize {
2535 self.max_tool_output
2536 }
2537
2538 pub fn bash_timeout(&self) -> u64 {
2539 self.bash_timeout
2540 }
2541
2542 pub fn bash_max_timeout(&self) -> u64 {
2543 self.bash_max_timeout
2544 }
2545
2546 pub fn subagent_timeout(&self) -> u64 {
2547 self.subagent_timeout
2548 }
2549
2550 pub fn api_retries(&self) -> u32 {
2551 self.api_retries
2552 }
2553
2554 pub fn set_max_tool_output(&mut self, v: usize) {
2555 self.max_tool_output = v;
2556 }
2557
2558 pub fn set_bash_timeout(&mut self, v: u64) {
2559 self.bash_timeout = v;
2560 }
2561
2562 pub fn set_bash_max_timeout(&mut self, v: u64) {
2563 self.bash_max_timeout = v;
2564 }
2565
2566 pub fn set_subagent_timeout(&mut self, v: u64) {
2567 self.subagent_timeout = v;
2568 }
2569
2570 pub fn set_api_retries(&mut self, v: u32) {
2571 self.api_retries = v;
2572 }
2573
2574 pub fn telemetry_level(&self) -> crate::runtime::telemetry::TelemetryLevel {
2575 self.telemetry_level
2576 }
2577
2578 pub fn set_telemetry_level(&mut self, level: crate::runtime::telemetry::TelemetryLevel) {
2579 self.telemetry_level = level;
2580 self.sync_observability();
2581 }
2582
2583 fn sync_observability(&mut self) {
2590 if self.telemetry_level.enabled() {
2591 if self.telemetry_writer.is_none() {
2592 let writer = crate::runtime::telemetry::TelemetryWriter::new(
2593 crate::runtime::telemetry::WriterOptions::default(),
2594 );
2595 self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
2596 crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
2597 ));
2598 self.telemetry_writer = Some(writer);
2599 }
2600 } else {
2601 self.telemetry_writer = None;
2605 self.trace_ctx = trace::TraceContext::disabled();
2606 }
2607 }
2608
2609 pub fn trace_context(&self) -> trace::TraceContext {
2611 self.trace_ctx.clone()
2612 }
2613
2614 pub(crate) fn effective_trace_context(&self) -> trace::TraceContext {
2635 match self.trace_controls.consume() {
2636 None => self.trace_ctx.clone(),
2637 Some(with_content) => {
2638 let base = if self.trace_ctx.enabled() {
2639 self.trace_ctx.clone()
2640 } else {
2641 let writer = crate::runtime::telemetry::TelemetryWriter::new(
2642 crate::runtime::telemetry::WriterOptions::default(),
2643 );
2644 *self
2648 .one_shot_trace_writer
2649 .lock()
2650 .expect("one-shot trace writer lock poisoned") = Some(writer.clone());
2651 self.trace_ctx
2652 .fork_with_sink(Arc::new(crate::runtime::telemetry::WriterTraceSink::new(
2653 writer,
2654 )))
2655 .with_one_shot_request_gate()
2656 };
2657 if with_content {
2658 base.with_content_capture(Arc::new(trace::ContentCapture::new(
2659 self.capture_dir.clone(),
2660 )))
2661 } else {
2662 base
2663 }
2664 }
2665 }
2666 }
2667
2668 pub fn trace_arm_next(&self, with_content: bool) {
2672 self.trace_controls.arm_next(with_content);
2673 }
2674
2675 pub fn trace_status(&self) -> trace::TraceStatusReport {
2680 let _ = trace::sweep_expired_captures(&self.capture_dir);
2681 trace::TraceStatusReport {
2682 persistent_enabled: self.telemetry_writer.is_some(),
2683 arm: self.trace_controls.peek(),
2684 trace_path: crate::runtime::telemetry::default_trace_log_path(),
2685 writer_stats: self.telemetry_writer.as_ref().map(|w| w.stats()),
2686 degraded_records: self.trace_ctx.degraded_records(),
2687 }
2688 }
2689
2690 fn memory_context_lock(&self) -> std::sync::MutexGuard<'_, memory_context::SessionMemoryState> {
2697 self.memory_context_state
2698 .lock()
2699 .unwrap_or_else(std::sync::PoisonError::into_inner)
2700 }
2701
2702 pub fn memory_context_status(&self) -> memory_context::MemoryContextStatus {
2705 self.memory_context_lock().status()
2706 }
2707
2708 pub fn memory_context_disable(&self) -> memory_context::MemoryContextStatus {
2722 let (status, bound, session) = {
2723 let mut state = self.memory_context_lock();
2724 let session = state.session_id().clone();
2725 let bound = state.bound_provider_ids();
2726 let status = match memory_context::apply_memory_context_action(
2727 &mut state,
2728 memory_context::AuthorizedMemoryAction::Disable {
2729 session: session.clone(),
2730 },
2731 ) {
2732 Ok(status) => status,
2733 Err(_) => state.status(),
2737 };
2738 (status, bound, session)
2739 };
2740 memory_context::emit_memory_observability_event(
2743 &memory_context::MemoryObservabilityEvent::context_disabled(
2744 &session,
2745 &self.memory_context_project_id(),
2746 ),
2747 );
2748 self.revoke_memory_provider_leases(&bound);
2752 status
2753 }
2754
2755 fn revoke_memory_provider_leases(&self, bound: &[memory_context::ContextProviderId]) {
2763 let Some(extension_runtime) = &self.extension_runtime else {
2764 return;
2765 };
2766 for provider in bound {
2767 let mut parts = provider.as_str().splitn(3, ':');
2768 if let (Some("extension"), Some(plugin), Some(_local_id)) =
2769 (parts.next(), parts.next(), parts.next())
2770 {
2771 if !plugin.is_empty() {
2772 extension_runtime.revoke_plugin_lease(&self.host_tool_session, plugin);
2773 }
2774 }
2775 }
2776 }
2777
2778 fn resolve_memory_provider(
2798 &self,
2799 requested: Option<&str>,
2800 ) -> std::result::Result<memory_context::ContextProviderId, memory_context::MemoryContextError>
2801 {
2802 use crate::extensions::context_provider as ext_cp;
2803 if (self.memory_backend.exclusive() && !self.memory_backend.is_axel())
2804 || self
2805 .memory_backend_reconfigure_denied
2806 .load(std::sync::atomic::Ordering::SeqCst)
2807 {
2808 return Err(memory_context::MemoryContextError::ProviderNotRegistered);
2810 }
2811 if self.memory_backend.is_axel() {
2812 self.memory_backend
2813 .validate_axel_config()
2814 .map_err(|_| memory_context::MemoryContextError::ProviderNotRegistered)?;
2815 if requested.is_some_and(|id| id != axel_context::PROVIDER_ID) {
2816 return Err(memory_context::MemoryContextError::ProviderNotRegistered);
2817 }
2818 return memory_context::ContextProviderId::parse(axel_context::PROVIDER_ID);
2819 }
2820 let Some(extension_runtime) = &self.extension_runtime else {
2821 return Ok(memory_provider_id());
2822 };
2823 let requested_id = match requested {
2824 None => None,
2825 Some(raw) => Some(
2828 ext_cp::ContextProviderId::parse(raw)
2829 .map_err(|_| memory_context::MemoryContextError::ProviderNotRegistered)?,
2830 ),
2831 };
2832 match extension_runtime.resolve_context_provider(requested_id.as_ref()) {
2833 Ok(descriptor) => {
2834 memory_context::ContextProviderId::parse(&descriptor.runtime_address())
2835 }
2836 Err(ext_cp::ContextProviderLookupError::NotRegistered) => {
2837 Err(memory_context::MemoryContextError::ProviderNotRegistered)
2838 }
2839 Err(ext_cp::ContextProviderLookupError::Ambiguous) => {
2840 Err(memory_context::MemoryContextError::ProviderAmbiguous)
2841 }
2842 }
2843 }
2844
2845 pub(crate) fn memory_context_enable(
2853 &self,
2854 mode: memory_context::MemoryContextMode,
2855 proof: memory_context::UserIntentProof,
2856 ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
2857 {
2858 self.memory_context_enable_resolved(mode, proof, None)
2859 }
2860
2861 pub(crate) fn memory_context_enable_resolved(
2866 &self,
2867 mode: memory_context::MemoryContextMode,
2868 proof: memory_context::UserIntentProof,
2869 requested_provider: Option<&str>,
2870 ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
2871 {
2872 let mut state = self.memory_context_lock();
2873 let lease = self.grant_command_memory_lease(&state, mode, proof, requested_provider)?;
2874 let enabled_event = memory_context::MemoryObservabilityEvent::context_enabled(&lease);
2878 let result = memory_context::apply_memory_context_action(
2879 &mut state,
2880 memory_context::AuthorizedMemoryAction::Enable { lease },
2881 );
2882 if result.is_ok() {
2883 memory_context::emit_memory_observability_event(&enabled_event);
2884 }
2885 result
2886 }
2887
2888 pub(crate) fn memory_context_recall_once(
2893 &self,
2894 proof: memory_context::UserIntentProof,
2895 ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
2896 {
2897 let mut state = self.memory_context_lock();
2898 let lease = self.grant_command_memory_lease(
2899 &state,
2900 memory_context::MemoryContextMode::RecallOnce,
2901 proof,
2902 None,
2903 )?;
2904 let enabled_event = memory_context::MemoryObservabilityEvent::context_enabled(&lease);
2907 let result = memory_context::apply_memory_context_action(
2908 &mut state,
2909 memory_context::AuthorizedMemoryAction::RecallOnce { lease },
2910 );
2911 if result.is_ok() {
2912 memory_context::emit_memory_observability_event(&enabled_event);
2913 }
2914 result
2915 }
2916
2917 fn grant_command_memory_lease(
2924 &self,
2925 state: &memory_context::SessionMemoryState,
2926 mode: memory_context::MemoryContextMode,
2927 proof: memory_context::UserIntentProof,
2928 requested_provider: Option<&str>,
2929 ) -> std::result::Result<memory_context::MemoryContextLease, memory_context::MemoryContextError>
2930 {
2931 let provider_id = self.resolve_memory_provider(requested_provider)?;
2932 memory_context::MemoryContextLease::grant(
2933 memory_context::MemoryLeaseId::parse(&format!("memctx-cmd-{}", uuid::Uuid::new_v4()))?,
2934 state.session_id().clone(),
2935 self.memory_context_project_id(),
2936 provider_id,
2937 mode,
2938 memory_context::CapturePolicy::default(),
2939 memory_context::RecallPolicy::default(),
2940 proof,
2941 std::time::SystemTime::now(),
2942 None,
2943 )
2944 }
2945
2946 #[cfg(test)]
2947 pub(crate) fn set_capture_provider_for_test(
2948 &self,
2949 provider: std::sync::Arc<dyn capture_worker::CaptureProvider>,
2950 ) {
2951 *self
2952 .capture_provider_for_test
2953 .lock()
2954 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(provider);
2955 }
2956
2957 pub(crate) fn compaction_capture_lease_at(
2958 &self,
2959 now: std::time::SystemTime,
2960 ) -> Option<memory_context::MemoryContextLease> {
2961 self.memory_context_lock().capture_lease_at(now)
2962 }
2963
2964 pub(crate) fn submit_compaction_summary_capture(
2965 &self,
2966 capture_lease: Option<memory_context::MemoryContextLease>,
2967 current: &agent_core::session::Session,
2968 api_messages: &[crate::SharedMessage],
2969 outcome: &compaction::CompactionOutcome,
2970 summarized_at: std::time::SystemTime,
2971 ) {
2972 let Some(lease) = capture_lease else {
2973 return;
2974 };
2975 let Ok(source_session_id) = memory_context::SessionId::parse(¤t.id) else {
2976 return;
2977 };
2978 if api_messages.is_empty() {
2983 return;
2984 }
2985 let first_turn_ordinal = 0;
2986 let last_turn_ordinal = (api_messages.len() - 1) as u64;
2987 let Some(source_digest) = chat_capture::MessageRangeDigest::from_hex(
2988 &agent_core::compaction::message_range_digest(api_messages),
2989 ) else {
2990 return;
2991 };
2992 let Some(prompt_stack_digest) =
2993 chat_capture::PromptStackDigest::from_hex(&outcome.prompt_stack_digest)
2994 else {
2995 return;
2996 };
2997 let summary_origin = if outcome.local_only {
2998 chat_capture::CompactionSummaryOrigin::LocalOnly
2999 } else {
3000 chat_capture::CompactionSummaryOrigin::Provider {
3001 provider_id: outcome.summary_provider.clone(),
3002 model_id: outcome.summary_model.clone(),
3003 }
3004 };
3005 let content_classes = outcome.included_classes.clone();
3006 let source = chat_capture::CompactionSource {
3007 schema: chat_capture::CompactionSchemaVersion::V1,
3008 project_id: lease.project_id.clone(),
3009 source_session_id,
3010 first_turn_ordinal,
3011 last_turn_ordinal,
3012 source_digest,
3013 summary_origin,
3014 prompt_stack_digest,
3015 redaction: match outcome.redaction_policy {
3016 agent_core::compaction::RedactionPolicy::TruncationOnly => {
3017 chat_capture::RedactionPolicy::None
3018 }
3019 agent_core::compaction::RedactionPolicy::PolicyExclusions => {
3020 chat_capture::RedactionPolicy::HostRedacted
3021 }
3022 },
3023 content_classes,
3024 summarized_at,
3025 };
3026
3027 #[cfg(any(test, feature = "testing"))]
3028 let test_provider = self
3029 .capture_provider_for_test
3030 .lock()
3031 .unwrap_or_else(std::sync::PoisonError::into_inner)
3032 .clone();
3033 #[cfg(not(any(test, feature = "testing")))]
3034 let test_provider: Option<std::sync::Arc<dyn capture_worker::CaptureProvider>> = None;
3035 let provider = test_provider.or_else(|| self.extension_capture_provider(&lease));
3036 if let Some(provider) = provider {
3037 let _ = memory_capture_worker().submit_summary(
3038 &lease,
3039 source,
3040 api_messages.len(),
3041 &outcome.summary_text,
3042 outcome.redaction_policy,
3043 memory_context::RetentionClass::Standard,
3044 provider,
3045 );
3046 }
3047 }
3048
3049 fn extension_capture_provider(
3050 &self,
3051 lease: &memory_context::MemoryContextLease,
3052 ) -> Option<std::sync::Arc<dyn capture_worker::CaptureProvider>> {
3053 if (self.memory_backend.exclusive() && !self.memory_backend.is_axel())
3054 || self
3055 .memory_backend_reconfigure_denied
3056 .load(std::sync::atomic::Ordering::SeqCst)
3057 {
3058 return None;
3059 }
3060 if self.memory_backend.is_axel() {
3061 if lease.provider_id.as_str() != axel_context::PROVIDER_ID
3062 || lease.project_id != self.memory_context_project_id()
3063 {
3064 return None;
3065 }
3066 return Some(Arc::new(axel_context::AxelCaptureProvider {
3067 binding: self.memory_backend.clone(),
3068 state: self.memory_context_state.clone(),
3069 lease: lease.clone(),
3070 }));
3071 }
3072 let manager = self.extension_runtime.clone()?;
3073 let mut parts = lease.provider_id.as_str().splitn(3, ':');
3074 let (Some("extension"), Some(plugin), Some(_)) = (parts.next(), parts.next(), parts.next())
3075 else {
3076 return None;
3077 };
3078 let digest =
3079 manager.declared_tool_digest(plugin, memory_context::MEMORY_CAPTURE_TOOL_NAME)?;
3080 let handle = tokio::runtime::Handle::try_current().ok()?;
3081 Some(std::sync::Arc::new(ExtensionCaptureProvider {
3082 manager,
3083 session: self.host_tool_session.clone(),
3084 plugin: plugin.to_owned(),
3085 digest,
3086 handle,
3087 }))
3088 }
3089
3090 pub fn memory_history_preview(
3094 &self,
3095 ) -> std::result::Result<memory_history::HistoryImportPreview, memory_history::HistoryImportError>
3096 {
3097 let host = memory_history::HistoryImportHostState::from_current_host()?;
3098 let mut io = memory_history::CanonicalHistoryMetadataIo::new();
3099 let preview = memory_history::preview_history_import(&host, &mut io)?;
3100 *self
3101 .pending_history_import_preview
3102 .lock()
3103 .expect("history preview mutex poisoned") = Some(preview.clone());
3104 Ok(preview)
3105 }
3106
3107 pub(crate) fn memory_history_confirm(
3111 &self,
3112 ) -> std::result::Result<memory_history::HistoryImportReport, memory_history::HistoryImportError>
3113 {
3114 let preview = self
3115 .pending_history_import_preview
3116 .lock()
3117 .expect("history preview mutex poisoned")
3118 .take()
3119 .ok_or(memory_history::HistoryImportError::ConsentRequired)?;
3120 let intent = memory_history::ConfirmedUserIntent::from_explicit_command(
3121 memory_context::mint_explicit_command_id(),
3122 );
3123 let mut io = memory_history::CanonicalHistoryMetadataIo::new();
3124 match memory_history::authorize_history_import(
3125 preview,
3126 memory_history::HistoryImportConsent::Confirmed(intent),
3127 memory_history::RequestAuthority::UserFrontend,
3128 &mut io,
3129 )? {
3130 memory_history::HistoryImportOutcome::Ready(plan) => {
3131 *self
3132 .history_import_plan
3133 .lock()
3134 .expect("history plan mutex poisoned") = Some(plan.clone());
3135 let lease = self
3136 .memory_context_lock()
3137 .capture_lease_at(std::time::SystemTime::now())
3138 .ok_or(memory_history::HistoryImportError::CaptureLeaseUnavailable)?;
3139 let provider = self
3142 .extension_capture_provider(&lease)
3143 .ok_or(memory_history::HistoryImportError::CaptureProviderUnavailable)?;
3144 let sessions_dir = agent_core::config::get_active_config_dir().join("sessions");
3145 memory_history::import_history_from_dir(
3146 &plan,
3147 &lease,
3148 &sessions_dir,
3149 memory_history::IMPORT_BATCH_MAX_RECORDS,
3150 memory_capture_worker(),
3151 provider,
3152 )
3153 }
3154 memory_history::HistoryImportOutcome::Declined => {
3155 Err(memory_history::HistoryImportError::ConsentRequired)
3156 }
3157 }
3158 }
3159
3160 pub(crate) fn memory_history_decline(&self) -> bool {
3162 self.pending_history_import_preview
3163 .lock()
3164 .expect("history preview mutex poisoned")
3165 .take()
3166 .is_some()
3167 }
3168
3169 #[cfg(test)]
3171 pub(crate) fn memory_durable_proof_for_test(&self) -> Option<memory_context::UserIntentProof> {
3172 self.memory_context_lock().durable_proof().cloned()
3173 }
3174
3175 #[cfg(test)]
3177 pub(crate) fn memory_one_shot_proof_for_test(&self) -> Option<memory_context::UserIntentProof> {
3178 self.memory_context_lock().one_shot_pending_proof().cloned()
3179 }
3180
3181 #[cfg(test)]
3184 pub(crate) fn memory_bound_providers_for_test(&self) -> Vec<memory_context::ContextProviderId> {
3185 self.memory_context_lock().bound_provider_ids()
3186 }
3187
3188 pub fn context_report(&self, history: Option<&[crate::SharedMessage]>) -> trace::ContextReport {
3197 use trace::ReportValue;
3198 let (history_messages, history_bytes) = match history {
3199 Some(msgs) => (
3200 ReportValue::Count(msgs.len() as u64),
3201 ReportValue::Count(
3202 msgs.iter()
3203 .map(|m| {
3204 serde_json::to_vec(&**m)
3205 .map(|v| v.len() as u64)
3206 .unwrap_or(0)
3207 })
3208 .sum(),
3209 ),
3210 ),
3211 None => (ReportValue::Unavailable, ReportValue::Unavailable),
3212 };
3213 let tool_count = match self.tools.try_read() {
3216 Ok(tools) => ReportValue::Count(tools.iter_tools_sorted().len() as u64),
3217 Err(_) => ReportValue::Unavailable,
3218 };
3219 trace::ContextReport {
3220 model: self.model.clone(),
3221 system_prompt_bytes: ReportValue::Count(
3222 self.system_prompt().map(|s| s.len() as u64).unwrap_or(0),
3223 ),
3224 tool_count,
3225 history_messages,
3226 history_bytes,
3227 loaded_skills: ReportValue::Unavailable,
3228 loaded_memories: ReportValue::Unavailable,
3229 cache: self.trace_ctx.cache_snapshots().last_activity(),
3230 trace_enabled: self.trace_ctx.enabled(),
3231 writer_stats: self.telemetry_writer.as_ref().map(|w| w.stats()),
3232 degraded_records: self.trace_ctx.degraded_records(),
3233 }
3234 }
3235
3236 pub fn telemetry_writer(&self) -> Option<crate::runtime::telemetry::TelemetryWriter> {
3238 self.telemetry_writer.clone()
3239 }
3240
3241 pub fn shutdown_observability(
3255 &self,
3256 timeout: std::time::Duration,
3257 ) -> Option<crate::runtime::telemetry::ShutdownOutcome> {
3258 let _ = trace::sweep_expired_captures(&self.capture_dir);
3263 let ephemeral = self
3269 .one_shot_trace_writer
3270 .lock()
3271 .expect("one-shot trace writer lock poisoned")
3272 .take();
3273 let ephemeral_outcome = ephemeral.map(|w| w.shutdown(timeout));
3274 self.telemetry_writer
3275 .as_ref()
3276 .map(|w| w.shutdown(timeout))
3277 .or(ephemeral_outcome)
3278 }
3279
3280 pub async fn shutdown_observability_async(
3293 &self,
3294 timeout: std::time::Duration,
3295 ) -> Option<crate::runtime::telemetry::ShutdownOutcome> {
3296 let capture_dir = self.capture_dir.clone();
3300 let _ =
3301 tokio::task::spawn_blocking(move || trace::sweep_expired_captures(&capture_dir)).await;
3302 let ephemeral = self
3304 .one_shot_trace_writer
3305 .lock()
3306 .expect("one-shot trace writer lock poisoned")
3307 .take();
3308 let ephemeral_outcome = match ephemeral {
3309 Some(writer) => Some(writer.shutdown_async(timeout).await),
3310 None => None,
3311 };
3312 match self.telemetry_writer.clone() {
3313 Some(writer) => Some(writer.shutdown_async(timeout).await),
3314 None => ephemeral_outcome,
3315 }
3316 }
3317
3318 #[cfg(any(test, feature = "testing"))]
3322 pub fn install_observability_for_tests(
3323 &mut self,
3324 writer: crate::runtime::telemetry::TelemetryWriter,
3325 ) {
3326 self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
3327 crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
3328 ));
3329 self.telemetry_writer = Some(writer);
3330 }
3331
3332 pub fn cache_diagnostics(&self) -> bool {
3333 self.cache_diagnostics
3334 }
3335
3336 pub fn set_cache_diagnostics(&mut self, v: bool) {
3337 self.cache_diagnostics = v;
3338 }
3339
3340 pub fn cache_ttl(&self) -> crate::core::config::CacheTtl {
3341 self.cache_ttl
3342 }
3343
3344 pub fn share_ttl_latches(&mut self, parent: &Runtime) {
3348 self.ttl_downgrade_notified = parent.ttl_downgrade_notified.clone();
3349 self.saw_1h_honored = parent.saw_1h_honored.clone();
3350 }
3351
3352 pub fn set_cache_ttl(&mut self, ttl: crate::core::config::CacheTtl) {
3356 self.cache_ttl = ttl;
3357 }
3358
3359 pub fn thinking_level(&self) -> &str {
3360 match self.named_level {
3363 Some(level) => level.as_str(),
3364 None => crate::core::models::thinking_level_for_budget(self.thinking_budget),
3365 }
3366 }
3367
3368 pub async fn refresh_if_needed(&self) -> Result<()> {
3370 self.refresh_if_needed_for_model(&self.model).await
3371 }
3372
3373 async fn refresh_if_needed_for_model(&self, model: &str) -> Result<()> {
3374 if !crate::runtime::auth::model_is_anthropic(model) {
3377 return Ok(());
3378 }
3379 AuthMethods::refresh_if_needed(
3380 Arc::clone(&self.auth),
3381 &self.client,
3382 &self.credential_source,
3383 &self.token_cache,
3384 )
3385 .await
3386 }
3387
3388 pub async fn compact_call(&self, messages: Vec<crate::SharedMessage>) -> Result<String> {
3394 self.remote_summarization_attempts
3397 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3398 let model = self.compaction_model();
3399 self.validate_request_preflight_for(
3400 model,
3401 crate::runtime::openai::catalog::CodexRequestRole::Internal,
3402 )
3403 .await?;
3404 self.refresh_if_needed_for_model(model).await?;
3405
3406 use crate::runtime::compaction::COMPACTION_SYSTEM_PROMPT;
3407
3408 ApiMethods::call_api_simple(
3409 &self.auth,
3410 &self.client,
3411 model,
3412 COMPACTION_SYSTEM_PROMPT,
3413 self.thinking_budget,
3414 self.reasoning_level(),
3415 &messages,
3416 self.api_retries,
3417 &api::ApiOptions {
3423 trace: self.trace_ctx.clone(),
3424 telemetry: self.telemetry_writer.clone(),
3425 ..api::ApiOptions::default()
3426 },
3427 )
3428 .await
3429 }
3430
3431 pub async fn run_single(&self, prompt: &str) -> Result<String> {
3434 if self
3435 .continuation
3436 .lock()
3437 .unwrap_or_else(std::sync::PoisonError::into_inner)
3438 .durability_blocked
3439 {
3440 return Err(RuntimeError::Session(
3441 "context head save is unresolved; reload the session before further inference"
3442 .into(),
3443 ));
3444 }
3445 self.validate_request_preflight().await?;
3446 let anthropic_execution_plan = self.authorized_anthropic_plan().await?;
3447 self.refresh_if_needed().await?;
3449
3450 let mut messages: Vec<crate::SharedMessage> = vec![std::sync::Arc::new(
3451 json!({"role": "user", "content": prompt}),
3452 )];
3453 let system_prompt = self.effective_system_prompt().await;
3454
3455 loop {
3456 let response = ApiMethods::call_api(
3457 &self.auth,
3458 &self.client,
3459 &self.model,
3460 &*self.tools.read().await,
3461 &system_prompt,
3462 self.thinking_budget,
3463 self.reasoning_level(),
3464 &messages,
3465 self.api_retries,
3466 &api::ApiOptions {
3467 memory_backend: None,
3468 use_1m_context: self.context_window_override == Some(1_000_000),
3469 cache_ttl: self.cache_ttl,
3470 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
3471 saw_1h_honored: self.saw_1h_honored.clone(),
3472 credential_source: self.credential_source.clone(),
3473 token_cache: self.token_cache.clone(),
3474 anthropic_base_url: None,
3475 anthropic_execution_plan: anthropic_execution_plan.clone(),
3476 codex_request_role: self.codex_request_role(),
3477 trace: self.effective_trace_context(),
3482 request_correlation: None,
3483 suppress_stream_deltas: true,
3484 telemetry: self.telemetry_writer.clone(),
3485 tool_session_id: Some(self.host_tool_session.clone()),
3492 session_tool_set: None,
3496 request_tools_schema: None,
3497 usage_counters: None,
3498 },
3499 )
3500 .await?;
3501
3502 if let Some(content) = response["content"].as_array() {
3504 let mut response_text = String::new();
3505 let mut tool_uses = Vec::new();
3506
3507 for item in content {
3509 match item["type"].as_str() {
3510 Some("text") => {
3511 if let Some(text) = item["text"].as_str() {
3512 response_text.push_str(text);
3513 }
3514 }
3515 Some("tool_use") => {
3516 tool_uses.push(item.clone());
3517 }
3518 _ => {}
3519 }
3520 }
3521
3522 if tool_uses.is_empty() {
3524 if let Some(orchestration) = &self.orchestration {
3525 if let agent_core::orchestration::CompletionGate::Blocked { workers } =
3526 orchestration.completion_gate()
3527 {
3528 return Err(crate::RuntimeError::Tool(format!(
3529 "completion blocked: {} worker(s) require collection/reconciliation: {} (call subagent_collect with reconciled=true after inspecting each result)",
3530 workers.len(),
3531 workers.join(", ")
3532 )));
3533 }
3534 }
3535 return Ok(response_text);
3536 }
3537
3538 messages.push(std::sync::Arc::new(json!({
3540 "role": "assistant",
3541 "content": content
3542 })));
3543
3544 let mut tool_results = Vec::new();
3546
3547 if tool_uses.len() == 1 {
3548 let tool_use = &tool_uses[0];
3550 if let (Some(tool_name), Some(tool_id)) =
3551 (tool_use["name"].as_str(), tool_use["id"].as_str())
3552 {
3553 let input = &tool_use["input"];
3554 let (result, rich_blocks) = match self.tools.read().await.get(tool_name).cloned() {
3555 Some(tool) => {
3556 let input = self
3557 .tools
3558 .read()
3559 .await
3560 .translate_input_for_api_tool(tool_name, input.clone());
3561 let runtime_name = self
3562 .tools
3563 .read()
3564 .await
3565 .runtime_name_for_api(tool_name)
3566 .to_string();
3567 let ctx = crate::ToolContext {
3568 channels: crate::tools::ToolChannels {
3569 tx_delta: None,
3570 tx_events: None,
3571 },
3572 capabilities: crate::tools::ToolCapabilities {
3573 launch_cancel: None,
3574 memory_backend: Some(self.memory_backend.clone()),
3575 watcher_exit_path: self.watcher_exit_path.clone(),
3576 tool_register_tx: None,
3577 session_manager: Some(self.session_manager.clone()),
3578 subagent_registry: Some(self.subagent_registry.clone()),
3579 delegation_parent: None,
3580 codex_parent_plan: Self::codex_delegation_plan(
3581 &self.model,
3582 self.reasoning_level(),
3583 self.codex_request_role(),
3584 ),
3585 event_queue: Some(self.event_queue.clone()),
3586 secret_prompt: None,
3587 orchestration: self.orchestration.clone(),
3588 tool_activation: None,
3589 mcp_leases: None,
3590 extension_leases: None,
3591 memory_context: self.memory_tool_capability(),
3592 cwd: self.cwd.clone(),
3593 env: self.env.clone(),
3594 env_stripped: self.env_stripped.clone(),
3595 env_warned: self.env_warned.clone(),
3596 },
3597 limits: crate::tools::ToolLimits {
3598 max_tool_output: self.max_tool_output,
3599 max_tool_buffer: 256 * 1024,
3600 bash_timeout: self.bash_timeout,
3601 bash_max_timeout: self.bash_max_timeout,
3602 subagent_timeout: self.subagent_timeout,
3603 },
3604 };
3605 let decision = resolve_before_tool_call_decision(
3606 input.clone(),
3607 emit_before_tool_call(
3608 &self.hook_bus,
3609 tool_name,
3610 Some(&runtime_name),
3611 input.clone(),
3612 self.session_id.as_deref(),
3613 )
3614 .await,
3615 None,
3616 false,
3617 )
3618 .await;
3619 if let BeforeToolCallDecision::Block { reason } = decision {
3620 (format!("Tool call blocked by extension: {}", reason), None)
3621 } else {
3622 let BeforeToolCallDecision::Continue { input } = decision
3623 else {
3624 unreachable!()
3625 };
3626 let input_for_hook = input.clone();
3627 let (output, rich_blocks) = match tool.execute_rich(input, ctx).await {
3628 Ok(output) => validated_single_tool_output(&self.model, output),
3629 Err(e) => (e.to_string(), None),
3630 };
3631 let hooked_output = emit_after_tool_call(
3632 &self.hook_bus,
3633 tool_name,
3634 Some(&runtime_name),
3635 input_for_hook,
3636 output.clone(),
3637 self.max_tool_output,
3638 self.session_id.as_deref(),
3639 )
3640 .await;
3641 let rich_blocks = retain_single_tool_blocks(rich_blocks, &output, &hooked_output);
3642 (hooked_output, rich_blocks)
3643 }
3644 }
3645 None => (format!("Unknown tool: {}", tool_name), None),
3646 };
3647 tool_results.push(json!({
3648 "type": "tool_result",
3649 "tool_use_id": tool_id,
3650 "content": single_tool_result_content(&result, rich_blocks, self.max_tool_output)
3651 }));
3652 }
3653 } else {
3654 let mut join_set = tokio::task::JoinSet::new();
3656
3657 let cfg_max_tool_output = self.max_tool_output;
3659 let cfg_bash_timeout = self.bash_timeout;
3660 let cfg_bash_max_timeout = self.bash_max_timeout;
3661 let cfg_subagent_timeout = self.subagent_timeout;
3662 let session_mgr = self.session_manager.clone();
3663 let cfg_subagent_registry = self.subagent_registry.clone();
3664 let cfg_event_queue = self.event_queue.clone();
3665 let cfg_hook_bus = self.hook_bus.clone();
3666 let cfg_orchestration = self.orchestration.clone();
3667 let codex_parent_plan = Self::codex_delegation_plan(
3668 &self.model,
3669 self.reasoning_level(),
3670 self.codex_request_role(),
3671 );
3672 let cfg_cwd = self.cwd.clone();
3673 let cfg_env = self.env.clone();
3674 let cfg_session_id = self.session_id.clone();
3675
3676 for tool_use in &tool_uses {
3677 if let (Some(tool_name), Some(tool_id)) = (
3678 tool_use["name"].as_str().map(|s| s.to_string()),
3679 tool_use["id"].as_str().map(|s| s.to_string()),
3680 ) {
3681 let input = tool_use["input"].clone();
3682 let tools_snapshot = self.tools.read().await;
3683 let input =
3684 tools_snapshot.translate_input_for_api_tool(&tool_name, input);
3685 let runtime_name =
3686 tools_snapshot.runtime_name_for_api(&tool_name).to_string();
3687 let tool = tools_snapshot.get(&tool_name).cloned();
3688 drop(tools_snapshot);
3689 let exit_path = self.watcher_exit_path.clone();
3690 let session_mgr_inner = session_mgr.clone();
3691 let registry_inner = cfg_subagent_registry.clone();
3692 let event_queue_inner = cfg_event_queue.clone();
3693 let hook_bus_inner = cfg_hook_bus.clone();
3694 let orchestration_inner = cfg_orchestration.clone();
3695 let memory_backend_inner = self.memory_backend.clone();
3696 let memory_context_inner = self.memory_tool_capability();
3697 let codex_parent_plan_inner = codex_parent_plan.clone();
3698 let cwd_inner = cfg_cwd.clone();
3699 let env_inner = cfg_env.clone();
3700 let env_stripped_inner = self.env_stripped.clone();
3701 let env_warned_inner = self.env_warned.clone();
3702 let session_id_inner = cfg_session_id.clone();
3703 let attachment_model = self.model.clone();
3704 let tool_name_for_hook = tool_name.clone();
3705 let runtime_name_for_hook = runtime_name.clone();
3706
3707 join_set.spawn(async move {
3708 let result = match tool {
3709 Some(t) => {
3710 let decision =
3711 crate::runtime::resolve_before_tool_call_decision(
3712 input.clone(),
3713 crate::runtime::emit_before_tool_call(
3714 &hook_bus_inner,
3715 &tool_name_for_hook,
3716 Some(&runtime_name_for_hook),
3717 input.clone(),
3718 session_id_inner.as_deref(),
3719 )
3720 .await,
3721 None,
3722 false,
3723 )
3724 .await;
3725 if let crate::runtime::BeforeToolCallDecision::Block {
3726 reason,
3727 } = decision
3728 {
3729 (format!("Tool call blocked by extension: {}", reason), None)
3730 } else {
3731 let crate::runtime::BeforeToolCallDecision::Continue {
3732 input,
3733 } = decision
3734 else {
3735 unreachable!()
3736 };
3737 let ctx = crate::ToolContext {
3738 channels: crate::tools::ToolChannels {
3739 tx_delta: None,
3740 tx_events: None,
3741 },
3742 capabilities: crate::tools::ToolCapabilities {
3743 launch_cancel: None,
3744 memory_backend: Some(memory_backend_inner),
3745 watcher_exit_path: exit_path,
3746 tool_register_tx: None,
3747 session_manager: Some(session_mgr_inner),
3748 subagent_registry: Some(registry_inner),
3749 delegation_parent: None,
3750 codex_parent_plan: codex_parent_plan_inner,
3751 event_queue: Some(event_queue_inner),
3752 secret_prompt: None,
3753 orchestration: orchestration_inner,
3754 tool_activation: None,
3755 mcp_leases: None,
3756 extension_leases: None,
3757 memory_context: memory_context_inner,
3758 cwd: cwd_inner,
3759 env: env_inner,
3760 env_stripped: env_stripped_inner,
3761 env_warned: env_warned_inner,
3762 },
3763 limits: crate::tools::ToolLimits {
3764 max_tool_output: cfg_max_tool_output,
3765 max_tool_buffer: 256 * 1024,
3766 bash_timeout: cfg_bash_timeout,
3767 bash_max_timeout: cfg_bash_max_timeout,
3768 subagent_timeout: cfg_subagent_timeout,
3769 },
3770 };
3771 let input_for_hook = input.clone();
3772 let (output, rich_blocks) =
3773 match t.execute_rich(input, ctx).await {
3774 Ok(output) => validated_single_tool_output(
3775 &attachment_model,
3776 output,
3777 ),
3778 Err(e) => (e.to_string(), None),
3779 };
3780 let hooked_output =
3781 crate::runtime::emit_after_tool_call(
3782 &hook_bus_inner,
3783 &tool_name_for_hook,
3784 Some(&runtime_name_for_hook),
3785 input_for_hook,
3786 output.clone(),
3787 cfg_max_tool_output,
3788 session_id_inner.as_deref(),
3789 )
3790 .await;
3791 let rich_blocks = retain_single_tool_blocks(
3792 rich_blocks,
3793 &output,
3794 &hooked_output,
3795 );
3796 (hooked_output, rich_blocks)
3797 }
3798 }
3799 None => (format!("Unknown tool: {}", tool_name), None),
3800 };
3801 (tool_id, result)
3802 });
3803 }
3804 }
3805
3806 let mut results_map = std::collections::HashMap::new();
3808 while let Some(res) = join_set.join_next().await {
3809 match res {
3810 Ok((tool_id, result)) => {
3811 results_map.insert(tool_id, result);
3812 }
3813 Err(e) => {
3814 tracing::error!("Parallel tool task panicked: {}", e);
3816 }
3817 }
3818 }
3819
3820 for tool_use in &tool_uses {
3822 if let Some(tool_id) = tool_use["id"].as_str() {
3823 let (result, rich_blocks) = results_map.remove(tool_id).unwrap_or_else(|| {
3824 ("Tool execution failed: task panicked".to_string(), None)
3825 });
3826 tool_results.push(json!({
3827 "type": "tool_result",
3828 "tool_use_id": tool_id,
3829 "content": single_tool_result_content(&result, rich_blocks, self.max_tool_output)
3830 }));
3831 }
3832 }
3833 }
3834
3835 messages.push(std::sync::Arc::new(json!({
3837 "role": "user",
3838 "content": tool_results
3839 })));
3840
3841 } else {
3843 return Err(RuntimeError::Tool("Invalid response format".to_string()));
3844 }
3845 }
3846 }
3847
3848 pub async fn run_stream(
3851 &self,
3852 prompt: String,
3853 cancel: CancellationToken,
3854 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
3855 self.run_stream_with_messages(
3856 vec![std::sync::Arc::new(
3857 json!({"role": "user", "content": prompt}),
3858 )],
3859 cancel,
3860 None,
3861 None,
3862 false,
3863 )
3864 .await
3865 }
3866
3867 pub async fn run_stream_with_messages(
3871 &self,
3872 messages: Vec<crate::SharedMessage>,
3873 cancel: CancellationToken,
3874 steering_rx: Option<mpsc::UnboundedReceiver<String>>,
3875 secret_prompt: Option<crate::tools::SecretPromptHandle>,
3876 auto_approve_confirms: bool,
3877 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
3878 let (tx, internal_rx) = mpsc::unbounded_channel();
3884 let bounded_rx =
3885 crate::runtime::relay::spawn_bounded_stream_relay(internal_rx, cancel.clone());
3886
3887 let turn_correlation_id = agent_core::next_turn_correlation_id();
3890
3891 if self
3892 .continuation
3893 .lock()
3894 .unwrap_or_else(std::sync::PoisonError::into_inner)
3895 .durability_blocked
3896 {
3897 let error = RuntimeError::Session(
3898 "context head save is unresolved; reload the session before further inference"
3899 .into(),
3900 );
3901 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3902 helpers::turn_error_for(&error, &turn_correlation_id),
3903 )));
3904 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3905 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3906 }
3907
3908 if let Err(error) = self.validate_request_preflight().await {
3909 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3910 helpers::turn_error_for(&error, &turn_correlation_id),
3911 )));
3912 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3913 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3914 }
3915
3916 let anthropic_execution_plan = match self.authorized_anthropic_plan().await {
3917 Ok(plan) => plan,
3918 Err(error) => {
3919 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3920 helpers::turn_error_for(&error, &turn_correlation_id),
3921 )));
3922 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3923 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3924 }
3925 };
3926
3927 if let Err(e) = self.refresh_if_needed().await {
3929 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3930 helpers::turn_error_for(&e, &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 let capture_started_at = std::time::SystemTime::now();
3946 let capture_turn_start = terminal_capture_start(&messages);
3947 let capture_lease = self
3950 .memory_context_lock()
3951 .capture_lease_at(capture_started_at);
3952 let mut messages = messages;
3953 self.apply_turn_memory_recall(&mut messages).await;
3954 let messages = messages;
3955
3956 let auth = Arc::clone(&self.auth);
3959 let client = self.client.clone();
3960 let credential_source = self.credential_source.clone();
3961 let token_cache = self.token_cache.clone();
3962 let model = self.model.clone();
3963 let tools = self.tools.clone();
3964 let system_prompt = self.effective_system_prompt().await;
3965 let thinking_budget = self.thinking_budget;
3966 let reasoning_level = self.reasoning_level();
3967 let watcher_exit_path = self.watcher_exit_path.clone();
3968 let max_tool_output = self.max_tool_output;
3969 let bash_timeout = self.bash_timeout;
3970 let bash_max_timeout = self.bash_max_timeout;
3971 let subagent_timeout = self.subagent_timeout;
3972 let api_retries = self.api_retries;
3973 let refusal_retries = self.refusal_retries;
3974 let session_manager = self.session_manager.clone();
3975 let subagent_registry = self.subagent_registry.clone();
3979 let reaper_registry = Arc::clone(&subagent_registry);
3982 let reaper_orchestration = self.orchestration.clone();
3983 let capture_provider = capture_lease
3984 .as_ref()
3985 .and_then(|lease| self.extension_capture_provider(lease));
3986 let capture_state = self.memory_context_state.clone();
3987 let event_queue = self.event_queue.clone();
3988 let options = api::ApiOptions {
3989 memory_backend: None,
3990 use_1m_context: self.context_window_override == Some(1_000_000),
3991 cache_ttl: self.cache_ttl,
3992 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
3993 saw_1h_honored: self.saw_1h_honored.clone(),
3994 credential_source: self.credential_source.clone(),
3995 token_cache: self.token_cache.clone(),
3996 anthropic_base_url: None,
3997 anthropic_execution_plan,
3998 codex_request_role: self.codex_request_role(),
3999 trace: self.effective_trace_context(),
4000 request_correlation: None,
4001 suppress_stream_deltas: false,
4002 telemetry: self.telemetry_writer.clone(),
4003 tool_session_id: Some(self.host_tool_session.clone()),
4006 session_tool_set: None,
4010 request_tools_schema: None,
4011 usage_counters: None,
4012 };
4013
4014 let final_capture_history: Arc<Mutex<Option<Vec<crate::SharedMessage>>>> = Arc::new(Mutex::new(None));
4015 let session = crate::runtime::stream::StreamSession {
4016 memory_backend: self.memory_backend.clone(),
4017 memory_context: self.memory_tool_capability(),
4018 final_capture_history: final_capture_history.clone(),
4019 context_window: self.context_window(),
4020 continuation: self.continuation.clone(),
4021 auth,
4022 client,
4023 credential_source,
4024 token_cache,
4025 options,
4026 api_retries,
4027 refusal_retries,
4028 model,
4029 tools,
4030 system_prompt,
4031 thinking_budget,
4032 reasoning_level,
4033 tx: tx.clone(),
4034 cancel,
4035 steering_rx,
4036 watcher_exit_path,
4037 max_tool_output,
4038 bash_timeout,
4039 bash_max_timeout,
4040 subagent_timeout,
4041 session_manager,
4042 subagent_registry,
4043 event_queue,
4044 secret_prompt,
4045 hook_bus: self.hook_bus.clone(),
4046 session_id: self.session_id.clone(),
4047 cwd: self.cwd.clone(),
4048 env: self.env.clone(),
4049 env_stripped: self.env_stripped.clone(),
4050 env_warned: self.env_warned.clone(),
4051 auto_approve_confirms,
4052 telemetry_level: self.telemetry_level,
4053 orchestration: self.orchestration.clone(),
4054 delegation_parent: self.delegation_parent.clone(),
4055 turn_correlation_id: turn_correlation_id.clone(),
4056 progressive_tool_disclosure: self.progressive_tool_disclosure,
4057 activation_confirm: self.activation_confirm,
4058 tool_session_id: self.host_tool_session.clone(),
4059 mcp_runtime: self.mcp_runtime.clone(),
4060 mcp_session_scope: self.mcp_session_scope.clone(),
4061 extension_runtime: self.extension_runtime.clone(),
4062 extension_session_scope: self.extension_session_scope.clone(),
4063 turn_budget: self.turn_budget.clone(),
4064 };
4065
4066 tokio::spawn(async move {
4067 let completed = match StreamMethods::run_stream_internal(session, messages).await {
4068 Ok(()) => true,
4069 Err(e) => {
4070 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
4071 helpers::turn_error_for(&e, &turn_correlation_id),
4072 )));
4073 false
4074 }
4075 };
4076 dispatch_completed_terminal_capture(
4079 completed,
4080 &final_capture_history,
4081 &capture_state,
4082 capture_lease.zip(capture_provider),
4083 capture_started_at,
4084 capture_turn_start.as_ref(),
4085 );
4086 crate::runtime::subagent::reap_finished(
4090 &reaper_registry,
4091 reaper_orchestration.as_deref(),
4092 );
4093 agent_core::core::memstat::log_turn_memory();
4094 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
4095 });
4096
4097 Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx))
4098 }
4099}
4100
4101impl Clone for Runtime {
4102 fn clone(&self) -> Self {
4103 Self {
4104 client: self.client.clone(),
4105 auth: Arc::clone(&self.auth),
4106 model: self.model.clone(),
4107 tools: self.tools.clone(),
4108 system_prompt: self.system_prompt.clone(),
4109 effective_prompt: self.effective_prompt.clone(),
4110 prompt_generation: self.prompt_generation,
4111 prompt_reload_source: self.prompt_reload_source.clone(),
4112 thinking_budget: self.thinking_budget,
4113 named_level: self.named_level,
4114 explicit_reasoning: self.explicit_reasoning,
4115 codex_request_role: self.codex_request_role,
4116 context_window_override: self.context_window_override,
4117 continuation: Arc::clone(&self.continuation),
4118 compaction_model: self.compaction_model.clone(),
4119 compaction_mode: self.compaction_mode,
4120 compaction_exclusions: self.compaction_exclusions.clone(),
4121 remote_summarization_attempts: Arc::clone(&self.remote_summarization_attempts),
4122 subagent_registry: self.subagent_registry.clone(),
4123 orchestration: self.orchestration.clone(),
4124 event_queue: self.event_queue.clone(),
4125 watcher_exit_path: self.watcher_exit_path.clone(),
4126 max_tool_output: self.max_tool_output,
4127 bash_timeout: self.bash_timeout,
4128 bash_max_timeout: self.bash_max_timeout,
4129 subagent_timeout: self.subagent_timeout,
4130 api_retries: self.api_retries,
4131 refusal_retries: self.refusal_retries,
4132 telemetry_level: self.telemetry_level,
4133 telemetry_writer: self.telemetry_writer.clone(),
4137 trace_ctx: self.trace_ctx.clone(),
4138 trace_controls: Arc::clone(&self.trace_controls),
4139 memory_context_state: std::sync::Arc::clone(&self.memory_context_state),
4145 memory_backend: self.memory_backend.clone(),
4146 memory_backend_config: self.memory_backend_config.clone(),
4147 memory_backend_reconfigure_denied: Arc::clone(&self.memory_backend_reconfigure_denied),
4148 pending_history_import_preview: std::sync::Arc::clone(
4149 &self.pending_history_import_preview,
4150 ),
4151 history_import_plan: std::sync::Arc::clone(&self.history_import_plan),
4152 #[cfg(any(test, feature = "testing"))]
4153 capture_provider_for_test: std::sync::Arc::clone(&self.capture_provider_for_test),
4154 pending_memory_segment: std::sync::Arc::clone(&self.pending_memory_segment),
4157 retained_recall_turn: std::sync::Arc::clone(&self.retained_recall_turn),
4160 one_shot_trace_writer: Arc::clone(&self.one_shot_trace_writer),
4161 capture_dir: self.capture_dir.clone(),
4162 cache_diagnostics: self.cache_diagnostics,
4163 cache_ttl: self.cache_ttl,
4164 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
4168 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
4169 last_msg_id: Arc::new(Mutex::new(None)),
4172 session_manager: self.session_manager.clone(),
4173 hook_bus: self.hook_bus.clone(),
4174 reaper_handle: None, reaper_cancel: None, credential_source: self.credential_source.clone(),
4177 token_cache: self.token_cache.clone(), trusted_worker_models: self.trusted_worker_models.clone(),
4179 progressive_tool_disclosure: self.progressive_tool_disclosure,
4180 activation_confirm: self.activation_confirm,
4181 delegation_parent: self.delegation_parent.clone(),
4182 mcp_runtime: self.mcp_runtime.clone(),
4183 mcp_session_scope: self.mcp_session_scope.clone(),
4186 extension_runtime: self.extension_runtime.clone(),
4187 extension_session_scope: self.extension_session_scope.clone(),
4189 turn_budget: self.turn_budget.clone(),
4190 host_tool_session: self.host_tool_session.clone(),
4195 session_id: self.session_id.clone(),
4197 cwd: self.cwd.clone(),
4198 env: self.env.clone(),
4199 env_stripped: self.env_stripped.clone(),
4200 env_warned: self.env_warned.clone(),
4201 }
4202 }
4203}
4204
4205#[cfg(test)]
4206mod rich_output_validation_tests {
4207 use super::*;
4208
4209 const MODEL: &str = "anthropic/claude-sonnet-4-6";
4210
4211 fn rich_output(summary: &str) -> crate::ToolOutput {
4212 crate::ToolOutput::Blocks {
4213 summary: summary.to_string(),
4214 blocks: vec![json!({
4216 "type": "image",
4217 "source": {
4218 "type": "base64",
4219 "media_type": "image/png",
4220 "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/l+QAAAAASUVORK5CYII="
4221 }
4222 })],
4223 }
4224 }
4225
4226 #[test]
4227 fn unchanged_summary_retains_rich_array_without_text_truncation() {
4228 let (summary, blocks) = validated_single_tool_output(MODEL, rich_output("image"));
4229 assert_eq!(summary, "image");
4230 let expected = blocks.clone().expect("supported image");
4231 assert_eq!(expected[0], json!({"type": "text", "text": summary}));
4232 assert_eq!(expected[1]["type"], "image");
4233 let retained = retain_single_tool_blocks(blocks, &summary, &summary);
4234 assert_eq!(
4235 single_tool_result_content(&summary, retained, summary.len()),
4236 Value::Array(expected)
4237 );
4238 }
4239
4240 #[test]
4241 fn rewritten_or_truncated_summary_drops_rich_blocks() {
4242 let (summary, blocks) = validated_single_tool_output(MODEL, rich_output("image summary"));
4243 assert!(blocks.is_some());
4244 for hooked in [
4245 "redacted".to_string(),
4246 String::new(),
4247 helpers::HelperMethods::truncate_tool_result(&summary, 5),
4248 ] {
4249 let retained = retain_single_tool_blocks(blocks.clone(), &summary, &hooked);
4250 assert!(retained.is_none());
4251 assert_eq!(
4252 single_tool_result_content(&hooked, retained, 5),
4253 Value::String(helpers::HelperMethods::truncate_tool_result(&hooked, 5))
4254 );
4255 }
4256 }
4257
4258 #[test]
4259 fn unsupported_or_malformed_media_becomes_explicit_text() {
4260 let mut malformed = rich_output("private summary");
4261 if let crate::ToolOutput::Blocks { blocks, .. } = &mut malformed {
4262 blocks[0]["source"]["data"] = json!("private invalid payload");
4263 }
4264 for (model, output) in [
4265 (
4266 "google-gemini/unsupported-rich-test",
4267 rich_output("private summary"),
4268 ),
4269 (MODEL, malformed),
4270 ] {
4271 let (summary, blocks) = validated_single_tool_output(model, output);
4272 assert!(summary.starts_with("Attachment not sent: "), "{summary}");
4273 assert!(!summary.contains("private"));
4274 assert!(blocks.is_none());
4275 assert_eq!(
4276 single_tool_result_content(&summary, blocks, 1024),
4277 Value::String(summary)
4278 );
4279 }
4280 }
4281
4282 #[test]
4283 fn plain_text_and_errors_keep_legacy_truncation() {
4284 for text in ["plain output", "tool error", "Unknown tool: missing"] {
4285 let (summary, blocks) = validated_single_tool_output(
4286 "google-gemini/unsupported-rich-test",
4287 crate::ToolOutput::Text(text.to_string()),
4288 );
4289 assert_eq!(summary, text);
4290 assert!(blocks.is_none());
4291 assert_eq!(
4292 single_tool_result_content(&summary, blocks, 5),
4293 Value::String(helpers::HelperMethods::truncate_tool_result(text, 5))
4294 );
4295 }
4296 }
4297}
4298
4299#[cfg(test)]
4300mod tests {
4301 use super::*;
4302
4303 #[test]
4308 fn host_tool_session_shared_by_clones_fresh_per_runtime() {
4309 let rt = Runtime::new_headless();
4310 let clone = rt.clone();
4311 assert_eq!(
4312 rt.host_tool_session_id(),
4313 clone.host_tool_session_id(),
4314 "clones share the live registry and therefore the host session"
4315 );
4316
4317 let other = Runtime::new_headless();
4318 assert_ne!(
4319 rt.host_tool_session_id(),
4320 other.host_tool_session_id(),
4321 "independently constructed runtimes must never share a session"
4322 );
4323 }
4324
4325 #[test]
4330 fn telemetry_level_gates_shared_observability_sink() {
4331 let mut rt = Runtime::new_headless();
4332 assert!(!rt.trace_context().enabled(), "off by default");
4333 assert!(rt.telemetry_writer().is_none());
4334
4335 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Basic);
4336 assert!(rt.trace_context().enabled(), "basic enables the trace sink");
4337 assert!(rt.telemetry_writer().is_some());
4338
4339 let ctx_before = format!("{:?}", rt.trace_context());
4341 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Full);
4342 assert_eq!(format!("{:?}", rt.trace_context()), ctx_before);
4343
4344 let clone = rt.clone();
4346 assert!(clone.trace_context().enabled());
4347 assert_eq!(format!("{:?}", clone.trace_context()), ctx_before);
4348 assert!(clone.telemetry_writer().is_some());
4349
4350 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Off);
4351 assert!(!rt.trace_context().enabled(), "off disables both");
4352 assert!(rt.telemetry_writer().is_none());
4353 assert!(rt
4354 .shutdown_observability(std::time::Duration::ZERO)
4355 .is_none());
4356 }
4357
4358 #[tokio::test]
4364 async fn shutdown_observability_async_epilogue_contract() {
4365 let rt = Runtime::new_headless();
4367 let budget = crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT;
4368 assert!(rt.shutdown_observability_async(budget).await.is_none());
4369 assert!(rt.shutdown_observability_async(budget).await.is_none());
4370
4371 let tmp = tempfile::tempdir().unwrap();
4373 let telemetry_path = tmp.path().join("synaps/api-log.jsonl");
4374 let mut rt = Runtime::new_headless();
4375 let writer = crate::runtime::telemetry::TelemetryWriter::new(
4376 crate::runtime::telemetry::WriterOptions {
4377 telemetry_path: Some(telemetry_path.clone()),
4378 trace_path: Some(tmp.path().join("synaps/request-trace.jsonl")),
4379 ..Default::default()
4380 },
4381 );
4382 rt.install_observability_for_tests(writer.clone());
4383 writer.enqueue_telemetry(crate::runtime::telemetry::TelemetryRecord {
4384 ts: 7,
4385 model: "claude-sonnet-4-6".to_string(),
4386 ..Default::default()
4387 });
4388 let outcome = rt
4389 .shutdown_observability_async(budget)
4390 .await
4391 .expect("writer installed");
4392 assert!(outcome.is_flushed());
4393 assert_eq!(outcome.stats().written, 1);
4394 assert_eq!(
4395 std::fs::read_to_string(&telemetry_path)
4396 .unwrap()
4397 .lines()
4398 .count(),
4399 1,
4400 "queued final record must be on disk after the epilogue flush"
4401 );
4402 assert!(rt
4404 .shutdown_observability_async(budget)
4405 .await
4406 .expect("writer still installed")
4407 .is_flushed());
4408
4409 let mut rt = Runtime::new_headless();
4411 let slow = crate::runtime::telemetry::TelemetryWriter::new(
4412 crate::runtime::telemetry::WriterOptions {
4413 telemetry_path: Some(tmp.path().join("slow/api-log.jsonl")),
4414 trace_path: Some(tmp.path().join("slow/request-trace.jsonl")),
4415 write_delay: Some(std::time::Duration::from_millis(300)),
4416 ..Default::default()
4417 },
4418 );
4419 rt.install_observability_for_tests(slow.clone());
4420 for _ in 0..10 {
4421 slow.enqueue_telemetry(crate::runtime::telemetry::TelemetryRecord::default());
4422 }
4423 let start = std::time::Instant::now();
4424 let outcome = rt
4425 .shutdown_observability_async(std::time::Duration::from_millis(200))
4426 .await
4427 .expect("writer installed");
4428 assert!(!outcome.is_flushed(), "10×300ms cannot drain in 200ms");
4429 assert!(
4432 start.elapsed() < std::time::Duration::from_millis(1500),
4433 "bounded epilogue must not block exit: {:?}",
4434 start.elapsed()
4435 );
4436 }
4437
4438 #[tokio::test]
4443 async fn one_shot_ephemeral_writer_is_retained_and_drained_at_shutdown() {
4444 let rt = Runtime::new_headless();
4445 assert!(!rt.trace_context().enabled(), "telemetry off");
4446 rt.trace_arm_next(false);
4447 let armed = rt.effective_trace_context();
4448 assert!(armed.enabled(), "armed ephemeral context traces");
4449 let budget = crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT;
4452 let outcome = rt
4453 .shutdown_observability_async(budget)
4454 .await
4455 .expect("retained one-shot writer must be drained by the epilogue");
4456 assert!(outcome.is_flushed());
4457 assert!(rt.shutdown_observability_async(budget).await.is_none());
4459 }
4460
4461 #[tokio::test]
4467 #[serial_test::serial(synaps_base_dir)]
4468 async fn startup_and_shutdown_paths_sweep_expired_captures() {
4469 struct BaseDirGuard(Option<String>);
4470 impl Drop for BaseDirGuard {
4471 fn drop(&mut self) {
4472 match self.0.take() {
4473 Some(old) => std::env::set_var("SYNAPS_BASE_DIR", old),
4474 None => std::env::remove_var("SYNAPS_BASE_DIR"),
4475 }
4476 }
4477 }
4478 let tmp = tempfile::tempdir().unwrap();
4479 let guard = BaseDirGuard(std::env::var("SYNAPS_BASE_DIR").ok());
4480 std::env::set_var("SYNAPS_BASE_DIR", tmp.path());
4481
4482 let cap_dir = trace::default_capture_dir();
4483 assert!(
4484 cap_dir.starts_with(tmp.path()),
4485 "capture dir must be private to the test"
4486 );
4487 let stale_id = trace::TraceId::new("req-stale").unwrap();
4488 let plant_stale = || {
4489 agent_core::core::private_fs::ensure_private_dir(&cap_dir).unwrap();
4490 let stale = trace::controls::ContentCaptureBundle {
4491 schema: trace::CONTENT_CAPTURE_SCHEMA.to_string(),
4492 request_id: stale_id.clone(),
4493 created_unix_ms: 1_000,
4494 expires_unix_ms: 2_000, redacted: true,
4496 over_budget: false,
4497 body: Some(serde_json::json!({"old": true})),
4498 };
4499 let path = trace::controls::capture_path(&cap_dir, &stale_id);
4500 std::fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap();
4501 path
4502 };
4503
4504 let stale_path = plant_stale();
4506 let rt = Runtime::new().await.expect("runtime constructs");
4507 assert!(
4508 !stale_path.exists(),
4509 "Runtime startup must physically remove expired capture bundles"
4510 );
4511
4512 let stale_path = plant_stale();
4514 let _ = rt.shutdown_observability(std::time::Duration::ZERO);
4515 assert!(
4516 !stale_path.exists(),
4517 "sync shutdown epilogue must remove expired capture bundles"
4518 );
4519
4520 let stale_path = plant_stale();
4522 let _ = rt
4523 .shutdown_observability_async(std::time::Duration::from_millis(100))
4524 .await;
4525 assert!(
4526 !stale_path.exists(),
4527 "async shutdown epilogue must remove expired capture bundles"
4528 );
4529 drop(guard);
4530 }
4531
4532 #[tokio::test]
4537 #[serial_test::serial(synaps_base_dir)]
4538 async fn capture_root_binds_at_construction_and_survives_env_churn() {
4539 let base_a = tempfile::tempdir().unwrap();
4540 let base_b = tempfile::tempdir().unwrap();
4541 let old = std::env::var("SYNAPS_BASE_DIR").ok();
4542
4543 std::env::set_var("SYNAPS_BASE_DIR", base_a.path());
4544 let rt_a = Runtime::new().await.expect("runtime A");
4545 std::env::set_var("SYNAPS_BASE_DIR", base_b.path());
4546 let rt_b = Runtime::new().await.expect("runtime B");
4547
4548 let cap_a = base_a.path().join("trace").join("capture");
4549 let cap_b = base_b.path().join("trace").join("capture");
4550 let plant = |dir: &std::path::Path, name: &str| {
4551 agent_core::core::private_fs::ensure_private_dir(dir).unwrap();
4552 let id = trace::TraceId::new(name).unwrap();
4553 let stale = trace::controls::ContentCaptureBundle {
4554 schema: trace::CONTENT_CAPTURE_SCHEMA.to_string(),
4555 request_id: id.clone(),
4556 created_unix_ms: 1_000,
4557 expires_unix_ms: 2_000,
4558 redacted: true,
4559 over_budget: false,
4560 body: Some(serde_json::json!({"old": true})),
4561 };
4562 let path = trace::controls::capture_path(dir, &id);
4563 std::fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap();
4564 path
4565 };
4566
4567 let decoy = tempfile::tempdir().unwrap();
4569 std::env::set_var("SYNAPS_BASE_DIR", decoy.path());
4570
4571 let stale_a = plant(&cap_a, "req-stale-a");
4572 let stale_b = plant(&cap_b, "req-stale-b");
4573 let _ = rt_a.shutdown_observability(std::time::Duration::ZERO);
4574 assert!(
4575 !stale_a.exists(),
4576 "runtime A must sweep the root bound at ITS construction"
4577 );
4578 assert!(
4579 stale_b.exists(),
4580 "runtime A must never sweep another runtime's root"
4581 );
4582 let _ = rt_b
4583 .shutdown_observability_async(std::time::Duration::from_millis(100))
4584 .await;
4585 assert!(
4586 !stale_b.exists(),
4587 "runtime B must sweep the root bound at ITS construction"
4588 );
4589
4590 match old {
4591 Some(v) => std::env::set_var("SYNAPS_BASE_DIR", v),
4592 None => std::env::remove_var("SYNAPS_BASE_DIR"),
4593 }
4594 }
4595
4596 #[test]
4597 fn reload_rejects_policy_change_without_partial_apply() {
4598 let dir = tempfile::tempdir().unwrap();
4599 let path = dir.path().join("prompt.yaml");
4600 let manifest = |total: &str, content: &str| {
4601 format!(
4602 "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"
4603 )
4604 };
4605 std::fs::write(&path, manifest("1", "before")).unwrap();
4606 let parsed =
4607 agent_core::prompt::PromptManifest::parse(&std::fs::read_to_string(&path).unwrap())
4608 .unwrap();
4609 let model =
4610 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-sonnet-4-6").unwrap();
4611 let context = agent_core::prompt::SelectionContext::new(model.clone(), None).unwrap();
4612 let stack = agent_core::prompt::compile_prompt_stack(
4613 &parsed,
4614 &parsed.registry(path.parent()).unwrap(),
4615 &context,
4616 None,
4617 )
4618 .unwrap();
4619 let catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
4620 &model,
4621 parsed.delegation_catalog_candidates(),
4622 )
4623 .unwrap();
4624 let digest = parsed
4625 .delegation_policy(model, &catalog)
4626 .unwrap()
4627 .map(|p| p.digest());
4628 let mut runtime = Runtime::new_headless();
4629 runtime.apply_prompt_stack(stack).unwrap();
4630 runtime.retain_prompt_reload_source(path.clone(), context, None, digest.clone());
4631 let generation = runtime.prompt_generation();
4632 let composed = runtime.effective_prompt().unwrap().composed().to_owned();
4633
4634 std::fs::write(&path, manifest("2", "after")).unwrap();
4635 let error = runtime.reload_prompt().unwrap_err().to_string();
4636 assert!(error.contains("cannot safely change delegation policy"));
4637 assert_eq!(runtime.prompt_generation(), generation);
4638 assert_eq!(runtime.effective_prompt().unwrap().composed(), composed);
4639 assert_eq!(
4640 runtime
4641 .prompt_reload_source
4642 .as_ref()
4643 .unwrap()
4644 .delegation_policy_digest,
4645 digest
4646 );
4647 }
4648
4649 #[test]
4650 fn worker_install_does_not_replay_process_global_favorite_grants() {
4651 let foreground =
4652 crate::orchestration::canonical_foreground_identity("anthropic/claude-fable-5")
4653 .unwrap();
4654 let session = Arc::new(
4655 crate::orchestration::OrchestrationRuntime::baseline(foreground, 4, 8).unwrap(),
4656 );
4657 let favorite = "openai-codex/gpt-5.6-sol";
4658 assert!(
4659 session.preflight(favorite).is_err(),
4660 "precondition: the parent session did not grant this identity"
4661 );
4662
4663 let mut worker = Runtime::new_headless();
4664 worker.apply_config(&crate::config::SynapsConfig {
4665 favorite_models: vec![favorite.to_owned()],
4666 ..Default::default()
4667 });
4668 worker.install_worker_orchestration(Arc::clone(&session));
4669
4670 assert!(
4671 session.preflight(favorite).is_err(),
4672 "constructing a child must not mint/replay a favorite-model grant"
4673 );
4674 assert!(!session.effective_choices().contains(&favorite.to_owned()));
4675 }
4676
4677 #[test]
4678 fn host_install_may_apply_explicit_operator_favorites() {
4679 let mut runtime = Runtime::new_headless();
4680 let config = crate::config::SynapsConfig {
4681 favorite_models: vec![
4682 "openai-codex/gpt-5.6-luna".to_owned(),
4683 "anthropic/claude-opus-4-6".to_owned(),
4684 "claude/claude-fable-5".to_owned(),
4686 "not-qualified".to_owned(),
4688 ],
4689 ..Default::default()
4690 };
4691 runtime.apply_config(&config);
4692 let foreground =
4693 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
4694 runtime.install_orchestration(std::sync::Arc::new(
4695 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap(),
4696 ));
4697
4698 let orchestration = runtime.orchestration().unwrap();
4699 for trusted in [
4700 "openai-codex/gpt-5.6-sol",
4701 "openai-codex/gpt-5.6-luna",
4702 "anthropic/claude-opus-4-6",
4703 "anthropic/claude-fable-5",
4704 ] {
4705 assert!(
4706 orchestration
4707 .effective_choices()
4708 .contains(&trusted.to_owned()),
4709 "missing configured worker choice {trusted}: {:?}",
4710 orchestration.effective_choices()
4711 );
4712 }
4713 let authorized = orchestration
4714 .resolve_and_authorize("sa_cross_provider", Some("anthropic/claude-opus-4-6"))
4715 .expect("an explicitly favorited cross-provider model must be authorized");
4716 assert_eq!(authorized.model.as_str(), "anthropic/claude-opus-4-6");
4717 assert!(!orchestration
4718 .effective_choices()
4719 .contains(&"not-qualified".to_owned()));
4720 }
4721
4722 #[test]
4723 fn configured_worker_choices_survive_manifestless_foreground_changes() {
4724 let mut runtime = Runtime::new_headless();
4725 runtime.apply_config(&crate::config::SynapsConfig {
4726 favorite_models: vec!["anthropic/claude-opus-4-6".to_owned()],
4727 ..Default::default()
4728 });
4729 let foreground =
4730 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
4731 runtime.install_orchestration(std::sync::Arc::new(
4732 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap(),
4733 ));
4734
4735 runtime
4736 .try_set_model("xai-auth/grok-4.5-latest".to_owned())
4737 .unwrap();
4738
4739 let orchestration = runtime.orchestration().unwrap();
4740 assert_eq!(orchestration.foreground_model(), "xai-auth/grok-4.5-latest");
4741 assert!(orchestration
4742 .effective_choices()
4743 .contains(&"anthropic/claude-opus-4-6".to_owned()));
4744 orchestration
4745 .resolve_and_authorize("sa_after_switch", Some("anthropic/claude-opus-4-6"))
4746 .expect("configured worker trust must survive policy replacement");
4747 }
4748
4749 #[test]
4750 fn set_model_replaces_manifestless_orchestration_foreground_snapshot() {
4751 let mut runtime = Runtime::new_headless();
4752 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4753 runtime.install_orchestration(std::sync::Arc::new(
4754 crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
4755 ));
4756
4757 runtime.set_model("openrouter/deepseek/deepseek-v4-pro".to_owned());
4758
4759 let orchestration = runtime.orchestration().unwrap();
4760 assert_eq!(
4761 orchestration.foreground_model(),
4762 "openrouter/deepseek/deepseek-v4-pro"
4763 );
4764 assert!(orchestration
4765 .effective_choices()
4766 .contains(&"openrouter/moonshotai/kimi-k2.7-code".to_owned()));
4767 assert!(orchestration
4768 .resolve_and_authorize("sa_kimi", Some("openrouter/moonshotai/kimi-k2.7-code"))
4769 .is_ok());
4770 }
4771
4772 #[test]
4773 fn set_model_refuses_to_orphan_unreconciled_policy_workers() {
4774 let mut runtime = Runtime::new_headless();
4775 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4776 let orchestration = std::sync::Arc::new(
4777 crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
4778 );
4779 orchestration
4780 .resolve_and_authorize("sa_active", None)
4781 .unwrap();
4782 runtime.install_orchestration(orchestration.clone());
4783 runtime.model = "openrouter/z-ai/glm-5.2".to_owned();
4784
4785 let error = runtime
4786 .try_set_model("openrouter/deepseek/deepseek-v4-pro".to_owned())
4787 .unwrap_err();
4788
4789 assert!(error.contains("collection/reconciliation"));
4790 assert_eq!(runtime.model(), "openrouter/z-ai/glm-5.2");
4791 assert!(std::sync::Arc::ptr_eq(
4792 runtime.orchestration().unwrap(),
4793 &orchestration
4794 ));
4795 assert!(orchestration.is_unreconciled("sa_active"));
4796 }
4797
4798 #[test]
4799 fn set_model_keeps_active_typed_manifest_orchestration_unchanged() {
4800 let dir = tempfile::tempdir().unwrap();
4801 let manifest_path = dir.path().join("prompt.yaml");
4802 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4803 let mut runtime = Runtime::new_headless();
4804 let orchestration = std::sync::Arc::new(
4805 crate::orchestration::OrchestrationRuntime::baseline(glm.clone(), 3, 8).unwrap(),
4806 );
4807 runtime.install_orchestration(orchestration.clone());
4808 runtime.retain_prompt_reload_source(
4809 manifest_path,
4810 agent_core::prompt::SelectionContext::new(glm, None).unwrap(),
4811 None,
4812 None,
4813 );
4814
4815 runtime.set_model("openrouter/deepseek/deepseek-v4-pro".to_owned());
4816
4817 assert!(std::sync::Arc::ptr_eq(
4818 runtime.orchestration().unwrap(),
4819 &orchestration
4820 ));
4821 assert_eq!(
4822 runtime.orchestration().unwrap().foreground_model(),
4823 "openrouter/z-ai/glm-5.2"
4824 );
4825 }
4826
4827 #[test]
4828 fn set_model_preserves_qualified_provider_ids_and_bare_claude() {
4829 let cases = [
4830 (
4831 "github-copilot/claude-opus-4.8",
4832 "github-copilot/claude-opus-4.8",
4833 ),
4834 ("anthropic/claude-sonnet-4-6", "anthropic/claude-sonnet-4-6"),
4835 ("google/gemini-2.5-pro", "google/gemini-2.5-pro"),
4836 ("openai/gpt-5", "openai/gpt-5"),
4837 ("claude-opus-4-8", "claude-opus-4-8"),
4838 ];
4839
4840 for (input, expected) in cases {
4841 let mut runtime = Runtime::new_headless();
4842 runtime.set_model(input.to_owned());
4843 assert_eq!(runtime.model(), expected, "input: {input}");
4844 }
4845 }
4846
4847 #[test]
4848 fn set_model_strips_only_legacy_health_status_decoration() {
4849 let cases = [
4850 ("✅ 339ms groq/llama-3.3-70b", "groq/llama-3.3-70b"),
4851 (
4852 "✅ 339ms github-copilot/claude-opus-4.8",
4853 "github-copilot/claude-opus-4.8",
4854 ),
4855 (
4856 "⚠️ 1200ms anthropic/claude-sonnet-4-6",
4857 "anthropic/claude-sonnet-4-6",
4858 ),
4859 ];
4860
4861 for (input, expected) in cases {
4862 let mut runtime = Runtime::new_headless();
4863 runtime.set_model(input.to_owned());
4864 assert_eq!(runtime.model(), expected, "input: {input}");
4865 }
4866 }
4867
4868 #[tokio::test]
4869 async fn confirm_without_prompt_fails_closed() {
4870 let result = resolve_before_tool_call_result(
4871 crate::extensions::hooks::events::HookResult::Confirm {
4872 message: "Run deploy?".into(),
4873 },
4874 None,
4875 false,
4876 )
4877 .await;
4878
4879 assert!(matches!(
4880 result,
4881 crate::extensions::hooks::events::HookResult::Block { reason }
4882 if reason.contains("requires confirmation") && reason.contains("Run deploy?")
4883 ));
4884 }
4885
4886 #[tokio::test]
4887 async fn modify_result_replaces_tool_input() {
4888 let result = resolve_before_tool_call_decision(
4889 serde_json::json!({"command":"rm -rf /"}),
4890 crate::extensions::hooks::events::HookResult::Modify {
4891 input: serde_json::json!({"command":"echo safe"}),
4892 },
4893 None,
4894 false,
4895 )
4896 .await;
4897
4898 match result {
4899 BeforeToolCallDecision::Continue { input } => {
4900 assert_eq!(input, serde_json::json!({"command":"echo safe"}));
4901 }
4902 BeforeToolCallDecision::Block { reason } => panic!("unexpected block: {reason}"),
4903 }
4904 }
4905
4906 #[tokio::test]
4907 async fn confirm_prompt_yes_continues() {
4908 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4909 let handle = crate::tools::SecretPromptHandle::new(tx);
4910
4911 let task = tokio::spawn(async move {
4912 let request = rx.recv().await.expect("confirm prompt request");
4913 assert_eq!(request.title, "Confirm tool call");
4914 assert!(request.prompt.contains("Run deploy?"));
4915 let _ = request.response_tx.send(Some("yes".to_string()));
4916 });
4917
4918 let result = resolve_before_tool_call_result(
4919 crate::extensions::hooks::events::HookResult::Confirm {
4920 message: "Run deploy?".into(),
4921 },
4922 Some(&handle),
4923 false,
4924 )
4925 .await;
4926
4927 task.await.unwrap();
4928 assert!(matches!(
4929 result,
4930 crate::extensions::hooks::events::HookResult::Continue
4931 ));
4932 }
4933
4934 #[tokio::test]
4935 async fn confirm_prompt_non_yes_blocks() {
4936 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4937 let handle = crate::tools::SecretPromptHandle::new(tx);
4938
4939 let task = tokio::spawn(async move {
4940 let request = rx.recv().await.expect("confirm prompt request");
4941 let _ = request.response_tx.send(Some("no".to_string()));
4942 });
4943
4944 let result = resolve_before_tool_call_result(
4945 crate::extensions::hooks::events::HookResult::Confirm {
4946 message: "Run deploy?".into(),
4947 },
4948 Some(&handle),
4949 false,
4950 )
4951 .await;
4952
4953 task.await.unwrap();
4954 assert!(matches!(
4955 result,
4956 crate::extensions::hooks::events::HookResult::Block { reason }
4957 if reason.contains("confirmation denied")
4958 ));
4959 }
4960
4961 #[test]
4962 fn test_max_tokens_for_model() {
4963 assert_eq!(
4965 HelperMethods::max_tokens_for_model("claude-opus-4-6"),
4966 128000
4967 );
4968 assert_eq!(
4969 HelperMethods::max_tokens_for_model("opus-something"),
4970 128000
4971 );
4972
4973 assert_eq!(
4975 HelperMethods::max_tokens_for_model("claude-sonnet-4-20250514"),
4976 64000
4977 );
4978 assert_eq!(HelperMethods::max_tokens_for_model("haiku"), 64000);
4979 assert_eq!(HelperMethods::max_tokens_for_model("claude-3-haiku"), 64000);
4980 assert_eq!(
4981 HelperMethods::max_tokens_for_model("some-other-model"),
4982 64000
4983 );
4984
4985 assert_eq!(HelperMethods::max_tokens_for_model(""), 64000);
4987 assert_eq!(HelperMethods::max_tokens_for_model("OPUS"), 64000); assert_eq!(
4989 HelperMethods::max_tokens_for_model("model-opus-end"),
4990 128000
4991 ); }
4993
4994 #[test]
4995 fn test_truncate_tool_result() {
4996 let default_max = 30000;
4997
4998 let short = "This is a short string.";
5000 assert_eq!(
5001 HelperMethods::truncate_tool_result(short, default_max),
5002 short
5003 );
5004
5005 let exact = "x".repeat(30000);
5007 assert_eq!(
5008 HelperMethods::truncate_tool_result(&exact, default_max),
5009 exact
5010 );
5011
5012 let too_long = "x".repeat(30001);
5014 let truncated = HelperMethods::truncate_tool_result(&too_long, default_max);
5015
5016 assert!(truncated.starts_with(&"x".repeat(30000)));
5018
5019 assert!(truncated.contains("[truncated — 30001 total bytes, showing first 30000]"));
5021
5022 assert!(truncated.len() > 30000);
5024
5025 let very_long = "a".repeat(50000);
5027 let truncated_very_long = HelperMethods::truncate_tool_result(&very_long, default_max);
5028 assert!(
5029 truncated_very_long.contains("[truncated — 50000 total bytes, showing first 30000]")
5030 );
5031 assert!(truncated_very_long.starts_with(&"a".repeat(30000)));
5032
5033 let custom_truncated = HelperMethods::truncate_tool_result(&very_long, 100);
5035 assert!(custom_truncated.starts_with(&"a".repeat(100)));
5036 assert!(custom_truncated.contains("[truncated — 50000 total bytes, showing first 100]"));
5037 }
5038
5039 #[test]
5043 fn truncate_tool_result_enforces_byte_budget() {
5044 let multibyte = "é".repeat(100); let out = HelperMethods::truncate_tool_result(&multibyte, 50);
5046 let body = out.split("\n\n[truncated").next().unwrap();
5047 assert!(
5048 body.len() <= 50,
5049 "retained body must fit the byte budget (got {} bytes)",
5050 body.len()
5051 );
5052 assert!(std::str::from_utf8(body.as_bytes()).is_ok());
5053 assert!(
5054 out.contains("total bytes"),
5055 "marker must report byte counts, got: {out}"
5056 );
5057 }
5058
5059 #[test]
5060 fn test_thinking_level_ranges() {
5061 use crate::core::models::thinking_level_for_budget;
5062
5063 assert_eq!(thinking_level_for_budget(0), "adaptive");
5065
5066 assert_eq!(thinking_level_for_budget(1), "low");
5068 assert_eq!(thinking_level_for_budget(1024), "low");
5069 assert_eq!(thinking_level_for_budget(2048), "low");
5070
5071 assert_eq!(thinking_level_for_budget(2049), "medium");
5073 assert_eq!(thinking_level_for_budget(3000), "medium");
5074 assert_eq!(thinking_level_for_budget(4096), "medium");
5075
5076 assert_eq!(thinking_level_for_budget(4097), "high");
5078 assert_eq!(thinking_level_for_budget(8192), "high");
5079 assert_eq!(thinking_level_for_budget(16384), "high");
5080
5081 assert_eq!(thinking_level_for_budget(16385), "xhigh");
5083 assert_eq!(thinking_level_for_budget(32768), "xhigh");
5084 assert_eq!(thinking_level_for_budget(100000), "xhigh");
5085 }
5086}
5087
5088#[cfg(test)]
5089mod effective_prompt_tests {
5090 use super::*;
5091
5092 fn headless(model: &str, base: &str) -> Runtime {
5093 let mut runtime = Runtime::new_headless();
5094 runtime.set_model(model.to_string());
5095 runtime.set_system_prompt(base.to_string());
5096 runtime
5097 }
5098
5099 #[tokio::test]
5100 async fn anthropic_foreground_ultracode_composes_standing_doctrine_once() {
5101 let mut runtime = headless("anthropic/claude-fable-5", "BASE.");
5102 runtime.named_level = Some(agent_core::reasoning::ReasoningLevel::UltraCode);
5103 let prompt = runtime.effective_system_prompt().await.expect("doctrine");
5104 assert_eq!(prompt.matches("<anthropic-ultracode-workflow>").count(), 1);
5105 assert!(prompt.contains("subagent_start"));
5106 assert!(prompt.contains("subagent_resume"));
5107 }
5108
5109 #[tokio::test]
5110 async fn codex_foreground_with_subagent_tools_composes_doctrine() {
5111 let runtime = headless("openai-codex/gpt-5.6-sol", "BASE.");
5112 let prompt = runtime
5113 .effective_system_prompt()
5114 .await
5115 .expect("prompt must compose");
5116 assert!(prompt.starts_with("BASE."), "base must lead");
5117 assert!(prompt.contains("## Subagent supervision"));
5118 assert!(prompt.contains("NEVER end your turn"));
5119 }
5120
5121 #[tokio::test]
5122 async fn worker_runtime_without_subagent_tools_stays_clean() {
5123 let mut runtime = headless("openai-codex/gpt-5.6-sol", "WORKER.");
5124 runtime.set_tools(ToolRegistry::without_subagent());
5125 assert_eq!(
5126 runtime.effective_system_prompt().await.as_deref(),
5127 Some("WORKER."),
5128 "workers have no subagent tools; doctrine would be unactionable noise"
5129 );
5130 }
5131
5132 #[tokio::test]
5133 async fn non_codex_foreground_stays_clean() {
5134 let runtime = headless("xai-auth/grok-4.5-latest", "BASE.");
5135 assert_eq!(
5136 runtime.effective_system_prompt().await.as_deref(),
5137 Some("BASE.")
5138 );
5139 }
5140
5141 #[tokio::test]
5142 async fn manifest_prompt_is_returned_verbatim() {
5143 let mut runtime = headless("openai-codex/gpt-5.6-sol", "IGNORED.");
5144 let module = agent_core::prompt::PromptModule::new(
5145 agent_core::prompt::PromptModuleId::parse("kernel.test").unwrap(),
5146 "1.0.0",
5147 agent_core::prompt::PromptModuleSource::User,
5148 0,
5149 agent_core::prompt::PromptSelectors::default(),
5150 agent_core::prompt::ModuleMutability::MutableGuidance,
5151 "KERNEL.",
5152 )
5153 .unwrap();
5154 let context = agent_core::prompt::SelectionContext::new(
5155 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap(),
5156 None,
5157 )
5158 .unwrap();
5159 let stack = agent_core::prompt::PromptStack::new(vec![module], context).unwrap();
5160 runtime.apply_prompt_stack(stack).unwrap();
5161 assert_eq!(
5162 runtime.effective_system_prompt().await.as_deref(),
5163 Some("KERNEL."),
5164 "manifest authors own the full stack; no builtin injection on top"
5165 );
5166 }
5167
5168 #[tokio::test]
5169 async fn unresolvable_model_identity_fails_closed_to_base() {
5170 let runtime = headless("definitely-not-a-model", "BASE.");
5171 assert_eq!(
5172 runtime.effective_system_prompt().await.as_deref(),
5173 Some("BASE.")
5174 );
5175 }
5176}
5177
5178#[cfg(test)]
5179mod set_reasoning_level_checked_tests {
5180 use super::*;
5181 use agent_core::reasoning::ReasoningLevel;
5182
5183 fn codex_runtime(model_id: &str) -> Runtime {
5184 let mut rt = Runtime::new_headless();
5185 rt.set_model(format!("openai-codex/{model_id}"));
5186 rt.set_reasoning_level(ReasoningLevel::Low);
5187 rt
5188 }
5189
5190 fn provisioned_fable_runtime() -> Runtime {
5191 let mut rt = Runtime::new_headless();
5192 rt.set_model("anthropic/claude-fable-5".into());
5193 rt.set_reasoning_level(ReasoningLevel::Low);
5194 let foreground =
5195 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-fable-5").unwrap();
5196 let orchestration =
5197 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap();
5198 rt.install_orchestration(Arc::new(orchestration));
5199 rt
5200 }
5201
5202 fn install_typed_manifest(rt: &mut Runtime) {
5203 let module = agent_core::prompt::PromptModule::new(
5204 agent_core::prompt::PromptModuleId::parse("kernel.test").unwrap(),
5205 "1.0.0",
5206 agent_core::prompt::PromptModuleSource::User,
5207 0,
5208 agent_core::prompt::PromptSelectors::default(),
5209 agent_core::prompt::ModuleMutability::MutableGuidance,
5210 "KERNEL.",
5211 )
5212 .unwrap();
5213 let context = agent_core::prompt::SelectionContext::new(
5214 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-fable-5").unwrap(),
5215 None,
5216 )
5217 .unwrap();
5218 rt.apply_prompt_stack(agent_core::prompt::PromptStack::new(vec![module], context).unwrap())
5219 .unwrap();
5220 }
5221
5222 #[test]
5223 fn provisioned_fable_accepts_max_and_ultracode() {
5224 for level in [ReasoningLevel::Max, ReasoningLevel::UltraCode] {
5225 let mut rt = provisioned_fable_runtime();
5226 rt.set_reasoning_level_checked(level).unwrap();
5227 assert_eq!(rt.reasoning_level(), level);
5228 }
5229 }
5230
5231 #[test]
5232 fn fable_ultracode_prerequisites_fail_without_mutation() {
5233 let mut missing_orchestration = Runtime::new_headless();
5234 missing_orchestration.set_model("anthropic/claude-fable-5".into());
5235 missing_orchestration.set_reasoning_level(ReasoningLevel::Low);
5236 assert!(missing_orchestration
5237 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5238 .is_err());
5239 assert_eq!(missing_orchestration.reasoning_level(), ReasoningLevel::Low);
5240
5241 let mut missing_tools = provisioned_fable_runtime();
5242 missing_tools.set_tools(ToolRegistry::without_subagent());
5243 assert!(missing_tools
5244 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5245 .is_err());
5246 assert_eq!(missing_tools.reasoning_level(), ReasoningLevel::Low);
5247
5248 let mut worker = provisioned_fable_runtime();
5249 worker.set_codex_request_role(crate::runtime::openai::catalog::ExecutionRole::Worker);
5250 assert!(worker
5251 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5252 .is_err());
5253 assert_eq!(worker.reasoning_level(), ReasoningLevel::Low);
5254
5255 let mut internal = provisioned_fable_runtime();
5256 internal.set_codex_request_role(crate::runtime::openai::catalog::ExecutionRole::Internal);
5257 assert!(internal
5258 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5259 .is_err());
5260 assert_eq!(internal.reasoning_level(), ReasoningLevel::Low);
5261
5262 let mut manifest = provisioned_fable_runtime();
5263 install_typed_manifest(&mut manifest);
5264 assert!(manifest
5265 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5266 .is_err());
5267 assert_eq!(manifest.reasoning_level(), ReasoningLevel::Low);
5268 }
5269
5270 #[test]
5271 fn checked_accepts_client_omission_modes_for_codex() {
5272 for level in [ReasoningLevel::Off, ReasoningLevel::Adaptive] {
5273 let mut rt = codex_runtime("gpt-5.6-sol");
5274 rt.set_reasoning_level_checked(level).unwrap();
5275 assert_eq!(rt.reasoning_level(), level);
5276 }
5277 }
5278
5279 #[test]
5281 fn checked_rejects_ultra_for_luna_no_mutation() {
5282 let mut rt = codex_runtime("gpt-5.6-luna");
5283 let before = rt.reasoning_level();
5284 let err = rt
5285 .set_reasoning_level_checked(ReasoningLevel::Ultra)
5286 .unwrap_err();
5287 assert!(
5288 err.contains("ultra"),
5289 "error must name the rejected level; got: {err}"
5290 );
5291 assert_eq!(
5292 rt.reasoning_level(),
5293 before,
5294 "runtime must not be mutated when validation fails"
5295 );
5296 }
5297
5298 #[test]
5300 fn checked_rejects_max_for_gpt55_no_mutation() {
5301 let mut rt = codex_runtime("gpt-5.5");
5302 rt.set_reasoning_level(ReasoningLevel::Medium);
5303 let err = rt
5304 .set_reasoning_level_checked(ReasoningLevel::Max)
5305 .unwrap_err();
5306 assert!(
5307 err.contains("max"),
5308 "error must name the rejected level; got: {err}"
5309 );
5310 assert_eq!(rt.reasoning_level(), ReasoningLevel::Medium);
5311 }
5312
5313 #[test]
5315 fn checked_accepts_ultra_for_sol_and_mutates() {
5316 let mut rt = codex_runtime("gpt-5.6-sol");
5317 rt.set_reasoning_level_checked(ReasoningLevel::Ultra)
5318 .expect("sol must accept ultra");
5319 assert_eq!(rt.reasoning_level(), ReasoningLevel::Ultra);
5320 }
5321
5322 #[test]
5324 fn checked_rejects_extended_levels_without_metadata() {
5325 let mut rt = Runtime::new_headless();
5326 rt.set_model("claude-opus-4-7".to_string());
5327 rt.set_reasoning_level(ReasoningLevel::Low);
5328 for level in [ReasoningLevel::Max, ReasoningLevel::Ultra] {
5329 assert!(rt.set_reasoning_level_checked(level).is_err());
5330 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
5331 }
5332 }
5333
5334 #[test]
5337 fn checked_rejects_max_ultra_for_unknown_codex_model() {
5338 let mut rt = codex_runtime("gpt-unknown-future");
5339 rt.set_reasoning_level(ReasoningLevel::Low);
5340 for level in [ReasoningLevel::Max, ReasoningLevel::Ultra] {
5341 let err = rt.set_reasoning_level_checked(level).unwrap_err();
5342 assert!(err.contains("no capability metadata"), "{err}");
5343 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
5344 }
5345 }
5346
5347 #[test]
5350 fn checked_enforces_xai_capability_matrix_no_mutation_on_err() {
5351 let mut rt = Runtime::new_headless();
5352 rt.set_model("xai-auth/grok-4.5".to_string());
5353 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5355 for level in [
5356 ReasoningLevel::Off,
5357 ReasoningLevel::XHigh,
5358 ReasoningLevel::Ultra,
5359 ] {
5360 let before = rt.reasoning_level();
5361 assert!(rt.set_reasoning_level_checked(level).is_err(), "{level}");
5362 assert_eq!(rt.reasoning_level(), before);
5363 }
5364 rt.set_reasoning_level_checked(ReasoningLevel::Low)
5365 .expect("grok-4.5 supports low");
5366 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
5367 }
5368
5369 #[test]
5372 fn checked_rejects_named_on_intrinsic_xai_model_and_defaults_adaptive() {
5373 let mut rt = Runtime::new_headless();
5374 rt.set_model("xai-auth/grok-4.3".to_string());
5375 assert_eq!(rt.reasoning_level(), ReasoningLevel::Adaptive);
5376 assert!(rt
5377 .set_reasoning_level_checked(ReasoningLevel::Medium)
5378 .is_err());
5379 assert_eq!(rt.reasoning_level(), ReasoningLevel::Adaptive);
5380 rt.set_reasoning_level_checked(ReasoningLevel::Adaptive)
5381 .unwrap();
5382 }
5383
5384 #[test]
5386 fn explicit_level_survives_xai_model_switch() {
5387 let mut rt = Runtime::new_headless();
5388 rt.set_model("xai-auth/grok-4.5".to_string());
5389 rt.set_reasoning_level_checked(ReasoningLevel::Low).unwrap();
5390 rt.set_model("xai-auth/grok-4.5-latest".to_string());
5391 assert_eq!(
5392 rt.reasoning_level(),
5393 ReasoningLevel::Low,
5394 "explicit level must not be overwritten by the model default"
5395 );
5396 }
5397
5398 #[test]
5401 fn explicit_unsupported_level_is_clamped_to_model_default_on_switch() {
5402 let mut rt = Runtime::new_headless();
5403 rt.set_reasoning_level_explicit(ReasoningLevel::XHigh);
5404 let clamp = rt
5405 .try_set_model("xai-auth/grok-4.6".to_string())
5406 .expect("model switch succeeds");
5407 assert_eq!(
5408 clamp,
5409 Some(ReasoningClamp {
5410 from: ReasoningLevel::XHigh,
5411 to: ReasoningLevel::High,
5412 })
5413 );
5414 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5415 assert!(
5416 rt.is_reasoning_explicit(),
5417 "clamp keeps explicit provenance"
5418 );
5419 assert_eq!(rt.model(), "xai-auth/grok-4.6");
5420 }
5421
5422 #[test]
5424 fn explicit_supported_level_is_not_clamped_on_switch() {
5425 let mut rt = Runtime::new_headless();
5426 rt.set_reasoning_level_explicit(ReasoningLevel::High);
5427 let clamp = rt.try_set_model("xai-auth/grok-4.6".to_string()).unwrap();
5428 assert_eq!(clamp, None);
5429 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5430 assert!(rt.is_reasoning_explicit());
5431 }
5432
5433 #[test]
5436 fn non_explicit_default_application_reports_no_clamp() {
5437 let mut rt = Runtime::new_headless();
5438 rt.set_reasoning_level(ReasoningLevel::XHigh);
5439 let clamp = rt.try_set_model("xai-auth/grok-4.6".to_string()).unwrap();
5440 assert_eq!(clamp, None);
5441 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5442 assert!(!rt.is_reasoning_explicit());
5443 }
5444
5445 #[test]
5448 fn explicit_max_clamps_to_validating_option_on_multi_agent_model() {
5449 let model = "xai-auth/grok-4.20-multi-agent-0309";
5450 let mut rt = Runtime::new_headless();
5451 rt.set_reasoning_level_explicit(ReasoningLevel::Max);
5452 let clamp = rt
5453 .try_set_model(model.to_string())
5454 .unwrap()
5455 .expect("Max is not supported: must clamp");
5456 assert_eq!(clamp.from, ReasoningLevel::Max);
5457 assert!(
5458 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
5459 model, clamp.to
5460 )
5461 .is_ok(),
5462 "clamped level {} must validate",
5463 clamp.to
5464 );
5465 assert_eq!(rt.reasoning_level(), clamp.to);
5466 assert!(rt.is_reasoning_explicit());
5467 }
5468
5469 #[test]
5471 fn set_model_returns_clamp() {
5472 let mut rt = Runtime::new_headless();
5473 rt.set_reasoning_level_explicit(ReasoningLevel::XHigh);
5474 let clamp = rt.set_model("xai-auth/grok-4.6".to_string());
5475 assert_eq!(clamp.map(|c| c.to), Some(ReasoningLevel::High));
5476 }
5477
5478 #[test]
5481 fn restore_session_reasoning_clamps_after_reapply() {
5482 let mut rt = Runtime::new_headless();
5483 rt.set_model("xai-auth/grok-4.6".to_string());
5484 let clamp = rt.restore_session_reasoning("xhigh");
5485 assert_eq!(
5486 clamp,
5487 Some(ReasoningClamp {
5488 from: ReasoningLevel::XHigh,
5489 to: ReasoningLevel::High,
5490 })
5491 );
5492 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5493 assert!(rt.is_reasoning_explicit());
5494 assert!(rt.set_reasoning_level_checked(rt.reasoning_level()).is_ok());
5495 }
5496
5497 #[test]
5499 fn restore_session_reasoning_keeps_supported_level() {
5500 let mut rt = Runtime::new_headless();
5501 rt.set_model("xai-auth/grok-4.6".to_string());
5502 assert_eq!(rt.restore_session_reasoning("low"), None);
5503 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
5504 assert!(rt.is_reasoning_explicit());
5505 }
5506
5507 #[test]
5510 fn apply_config_clamps_unsupported_explicit_level_against_config_model() {
5511 let mut rt = Runtime::new_headless();
5512 rt.apply_config(&crate::config::SynapsConfig {
5513 model: Some("xai-auth/grok-4.6".to_string()),
5514 thinking_level: Some(ReasoningLevel::XHigh),
5515 ..Default::default()
5516 });
5517 assert_eq!(rt.model(), "xai-auth/grok-4.6");
5518 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5519 assert!(rt.is_reasoning_explicit());
5520 assert!(rt.set_reasoning_level_checked(rt.reasoning_level()).is_ok());
5521 }
5522}
5523
5524#[cfg(test)]
5525mod codex_execution_preflight_tests {
5526 use super::*;
5527 use crate::runtime::openai::catalog::CodexRequestRole;
5528 use agent_core::prompt::QualifiedModelId;
5529 use agent_core::reasoning::ReasoningLevel;
5530
5531 fn ultra_runtime() -> Runtime {
5532 let mut runtime = Runtime::new_headless();
5533 runtime.set_model("openai-codex/gpt-5.6-sol".to_string());
5534 runtime.set_reasoning_level_explicit(ReasoningLevel::Ultra);
5535 runtime
5536 }
5537
5538 fn install_baseline(runtime: &mut Runtime) {
5539 let foreground =
5540 QualifiedModelId::parse("openai-codex/gpt-5.6-sol").expect("qualified model");
5541 let orchestration = crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8)
5542 .expect("baseline orchestration");
5543 runtime.install_orchestration(Arc::new(orchestration));
5544 }
5545
5546 #[tokio::test]
5547 async fn foreground_ultra_preflight_requires_orchestration() {
5548 let runtime = ultra_runtime();
5549 let error = runtime
5550 .validate_request_preflight()
5551 .await
5552 .expect_err("Ultra without orchestration must fail closed");
5553 assert!(error.to_string().contains("orchestration"), "{error}");
5554 }
5555
5556 #[tokio::test]
5557 async fn foreground_ultra_preflight_requires_actionable_subagent_tools() {
5558 let mut runtime = ultra_runtime();
5559 install_baseline(&mut runtime);
5560 runtime.set_tools(ToolRegistry::without_subagent());
5561
5562 let error = runtime
5563 .validate_request_preflight()
5564 .await
5565 .expect_err("Ultra without subagent tools must fail closed");
5566 assert!(error.to_string().contains("subagent"), "{error}");
5567 }
5568
5569 #[tokio::test]
5570 async fn foreground_ultra_preflight_accepts_exact_model_policy_and_tools() {
5571 let mut runtime = ultra_runtime();
5572 install_baseline(&mut runtime);
5573 runtime
5574 .validate_request_preflight()
5575 .await
5576 .expect("fully provisioned Ultra must pass preflight");
5577 }
5578
5579 #[tokio::test]
5580 async fn worker_ultra_preflight_never_requires_or_enables_orchestration() {
5581 let mut runtime = ultra_runtime();
5582 runtime.set_codex_request_role(CodexRequestRole::Worker);
5583 runtime.set_tools(ToolRegistry::without_subagent());
5584 runtime
5585 .validate_request_preflight()
5586 .await
5587 .expect("worker Ultra is underlying max without proactive orchestration");
5588 assert_eq!(runtime.codex_request_role(), CodexRequestRole::Worker);
5589 }
5590
5591 #[tokio::test]
5592 async fn non_codex_ultra_preflight_fails_before_provider_work() {
5593 let mut runtime = Runtime::new_headless();
5594 runtime.set_model("anthropic/claude-opus-4-7".to_string());
5595 runtime.set_reasoning_level_explicit(ReasoningLevel::Ultra);
5596 let error = runtime
5597 .validate_request_preflight()
5598 .await
5599 .expect_err("non-Codex Ultra must fail closed");
5600 assert!(
5601 error.to_string().contains("unsupported_reasoning_level"),
5602 "{error}"
5603 );
5604 }
5605
5606 #[tokio::test]
5607 async fn compaction_preflight_validates_target_before_credentials() {
5608 let mut runtime = ultra_runtime();
5609 runtime.set_compaction_model(Some("claude-sonnet-4-6".to_string()));
5610 {
5611 let mut auth = runtime.auth.write().await;
5612 auth.auth_token.clear();
5613 auth.auth_type = "none".to_string();
5614 auth.refresh_token = None;
5615 auth.token_expires = None;
5616 }
5617
5618 let error = runtime
5619 .compact_call(Vec::new())
5620 .await
5621 .expect_err("Codex Ultra cannot silently cross into Anthropic compaction");
5622 assert!(
5623 error.to_string().contains("authoritative exact-model"),
5624 "preflight must reject the target before auth access: {error}"
5625 );
5626 }
5627}
5628
5629#[cfg(test)]
5630mod http_client_timeout_tests {
5631 use futures::StreamExt;
5645 use std::time::Duration;
5646 use tokio::io::AsyncWriteExt;
5647
5648 const CHUNKED_HEAD: &[u8] =
5649 b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ntransfer-encoding: chunked\r\n\r\n";
5650
5651 fn chunk(data: &str) -> Vec<u8> {
5652 format!("{:x}\r\n{}\r\n", data.len(), data).into_bytes()
5653 }
5654
5655 async fn spawn_chunked_server(n_chunks: usize, gap: Duration, terminate: bool) -> String {
5658 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5659 let addr = listener.local_addr().unwrap();
5660 tokio::spawn(async move {
5661 let (mut sock, _) = listener.accept().await.unwrap();
5662 let mut buf = [0u8; 4096];
5664 use tokio::io::AsyncReadExt;
5665 let _ = sock.read(&mut buf).await;
5666 sock.write_all(CHUNKED_HEAD).await.unwrap();
5667 for i in 0..n_chunks {
5668 sock.write_all(&chunk(&format!("data {i}\n")))
5669 .await
5670 .unwrap();
5671 sock.flush().await.unwrap();
5672 tokio::time::sleep(gap).await;
5673 }
5674 if terminate {
5675 sock.write_all(b"0\r\n\r\n").await.unwrap();
5676 } else {
5677 tokio::time::sleep(Duration::from_secs(60)).await;
5679 }
5680 });
5681 format!("http://{addr}/stream")
5682 }
5683
5684 async fn drain(
5685 client: &reqwest::Client,
5686 url: &str,
5687 ) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
5688 let resp = client.get(url).send().await?;
5689 let mut stream = resp.bytes_stream();
5690 let mut total = 0usize;
5691 while let Some(c) = stream.next().await {
5692 total += c?.len();
5693 }
5694 Ok(total)
5695 }
5696
5697 #[tokio::test]
5702 async fn active_stream_outliving_read_timeout_survives() {
5703 let url = spawn_chunked_server(15, Duration::from_millis(100), true).await;
5705 let client = super::build_http_client(Duration::from_millis(400)).expect("client builds");
5706 let total = drain(&client, &url)
5707 .await
5708 .expect("active stream must never be killed by the idle timeout");
5709 assert!(total > 0);
5710 }
5711
5712 #[tokio::test]
5715 async fn stalled_stream_is_killed_by_read_timeout() {
5716 let url = spawn_chunked_server(2, Duration::from_millis(10), false).await;
5718 let client = super::build_http_client(Duration::from_millis(300)).expect("client builds");
5719 let start = std::time::Instant::now();
5720 let err = drain(&client, &url)
5721 .await
5722 .expect_err("stalled stream must be detected");
5723 assert!(
5724 start.elapsed() < Duration::from_secs(5),
5725 "stall must be detected promptly, took {:?}",
5726 start.elapsed()
5727 );
5728 let msg = crate::core::error::error_chain_string(err.as_ref());
5729 assert!(
5730 msg.contains("timed out") || msg.contains("timeout"),
5731 "must be a timeout error: {msg}"
5732 );
5733 }
5734
5735 #[tokio::test]
5739 async fn hung_headers_are_killed_by_read_timeout() {
5740 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5741 let addr = listener.local_addr().unwrap();
5742 tokio::spawn(async move {
5743 let (_sock, _) = listener.accept().await.unwrap();
5744 tokio::time::sleep(Duration::from_secs(60)).await;
5745 });
5746 let client = super::build_http_client(Duration::from_millis(300)).expect("client builds");
5747 let start = std::time::Instant::now();
5748 let err = client
5749 .get(format!("http://{addr}/codex/responses"))
5750 .send()
5751 .await
5752 .expect_err("hung request must be detected");
5753 assert!(
5754 start.elapsed() < Duration::from_secs(5),
5755 "hung headers must be detected promptly, took {:?}",
5756 start.elapsed()
5757 );
5758 assert!(err.is_timeout(), "must be a timeout: {err:?}");
5759 }
5760}
5761
5762#[cfg(test)]
5764mod memory_context_provider_tests {
5765 use super::{memory_context, Runtime};
5766 use std::sync::Arc;
5767
5768 async fn memory_runtime_with_providers(
5776 plugins: &[(&str, &str)],
5777 ) -> (
5778 Runtime,
5779 std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
5780 ) {
5781 let mut manager = crate::extensions::manager::ExtensionManager::new(Arc::new(
5782 crate::extensions::hooks::HookBus::new(),
5783 ));
5784 manager.set_progressive_deferral(true);
5785 for (plugin, provider) in plugins {
5786 let manifest: crate::extensions::manifest::ExtensionManifest =
5787 serde_json::from_value(serde_json::json!({
5788 "runtime": "process",
5789 "command": "/bin/false",
5790 "permissions": ["context_providers.register"],
5791 "deferred": {
5792 "context_providers": [{
5793 "id": provider,
5794 "capability": "project-memory",
5795 "description": "test context provider",
5796 "schema_version": 1
5797 }]
5798 }
5799 }))
5800 .expect("manifest parses");
5801 manager
5802 .load(plugin, &manifest)
5803 .await
5804 .expect("deferred context-provider load succeeds without spawning");
5805 }
5806 let extension_runtime = manager.extension_runtime();
5807 let mut runtime = Runtime::new_headless();
5808 runtime.install_extension_runtime(std::sync::Arc::clone(&extension_runtime));
5809 (runtime, extension_runtime)
5810 }
5811
5812 fn assert_memory_off_no_lease(runtime: &Runtime) {
5813 let status = runtime.memory_context_status();
5814 assert_eq!(
5815 status.durable,
5816 memory_context::DurableStatus::Off,
5817 "state must stay Off"
5818 );
5819 assert_eq!(
5820 status.one_shot,
5821 memory_context::OneShotStatus::Idle,
5822 "no one-shot lease may exist"
5823 );
5824 assert!(
5825 runtime.memory_bound_providers_for_test().is_empty(),
5826 "no provider may be bound"
5827 );
5828 }
5829
5830 #[tokio::test]
5833 async fn memory_backend_exclusive_rejects_extension_recall_capture_and_history() {
5834 for selector in ["axel", "invalid-selector"] {
5835 let (mut runtime, _manager) =
5836 memory_runtime_with_providers(&[("memory-test", "notes")]).await;
5837 let provider = runtime.resolve_memory_provider(None).unwrap();
5838 runtime.apply_config(&agent_core::config::load_config_from_str(&format!(
5839 "memory.backend = {selector}\n"
5840 )));
5841 assert!(runtime.memory_backend.exclusive());
5842 assert!(matches!(
5843 runtime.resolve_memory_provider(None),
5844 Err(memory_context::MemoryContextError::ProviderNotRegistered)
5845 ));
5846 assert!(matches!(
5847 runtime.resolve_memory_provider(Some(provider.as_str())),
5848 Err(memory_context::MemoryContextError::ProviderNotRegistered)
5849 ));
5850 assert!(matches!(
5851 runtime.memory_history_confirm(),
5852 Err(
5853 super::memory_history::HistoryImportError::CaptureProviderUnavailable
5854 | super::memory_history::HistoryImportError::ConsentRequired
5855 )
5856 ));
5857 assert_memory_off_no_lease(&runtime);
5858 let mut messages = vec![Arc::new(
5859 serde_json::json!({"role": "user", "content": "test"}),
5860 )];
5861 let original = messages.clone();
5862 runtime.apply_turn_memory_recall(&mut messages).await;
5863 assert_eq!(messages, original);
5864 let clone = runtime.clone();
5865 assert!(clone.memory_backend.exclusive());
5866 assert_eq!(clone.memory_backend.base(), runtime.memory_backend.base());
5867 }
5868 }
5869
5870 #[tokio::test]
5871 async fn memory_backend_first_config_is_immutable_and_changes_require_restart() {
5872 for (first, different) in [("legacy", "axel"), ("axel", "legacy")] {
5873 let mut runtime = Runtime::new_headless();
5874 let config =
5875 agent_core::config::load_config_from_str(&format!("memory.backend = {first}\n"));
5876 runtime.apply_config(&config);
5877 let binding = runtime.memory_backend.clone();
5878 runtime.apply_config(&config);
5879 assert!(!runtime
5880 .memory_backend_reconfigure_denied
5881 .load(std::sync::atomic::Ordering::SeqCst));
5882 if let (Ok(a), Ok(b)) = (binding.scope(), runtime.memory_backend.scope()) {
5883 assert!(std::ptr::eq(a, b));
5884 }
5885 let clone = runtime.clone();
5886 runtime.apply_config(&agent_core::config::load_config_from_str(&format!(
5887 "memory.backend = {different}\n"
5888 )));
5889 assert_eq!(runtime.memory_backend.exclusive(), binding.exclusive());
5890 assert_eq!(
5891 runtime.memory_backend_config.as_ref(),
5892 Some(&config.memory_backend)
5893 );
5894 for denied in [&runtime, &clone] {
5895 let error = denied
5896 .validate_request_preflight()
5897 .await
5898 .unwrap_err()
5899 .to_string();
5900 assert!(error.contains("restart required"), "{error}");
5901 assert!(denied.resolve_memory_provider(None).is_err());
5902 }
5903 runtime.apply_config(&config);
5904 assert!(runtime.validate_request_preflight().await.is_err());
5905 }
5906 }
5907
5908 #[tokio::test]
5909 async fn memory_backend_path_changes_also_require_restart() {
5910 for field in ["executable", "brain"] {
5911 let mut runtime = Runtime::new_headless();
5912 let config = crate::config::MemoryBackendConfig::default();
5913 runtime.apply_memory_backend_config(&config);
5914 let mut different = config.clone();
5915 let path = std::env::temp_dir().join("synaps-memory-config-test");
5916 if field == "executable" {
5917 different.executable = Some(path);
5918 } else {
5919 different.brain = Some(path);
5920 }
5921 runtime.apply_memory_backend_config(&different);
5922 assert_eq!(runtime.memory_backend_config.as_ref(), Some(&config));
5923 assert!(!runtime.memory_backend.exclusive());
5924 let error = runtime
5925 .validate_request_preflight()
5926 .await
5927 .unwrap_err()
5928 .to_string();
5929 assert!(error.contains("restart required"), "{error}");
5930 assert!(runtime.resolve_memory_provider(None).is_err());
5931 }
5932 }
5933
5934 #[test]
5935 fn memory_backend_apply_config_revokes_preexisting_legacy_lease() {
5936 let mut runtime = Runtime::new_headless();
5937 runtime
5938 .memory_context_enable(
5939 memory_context::MemoryContextMode::CaptureAndRecall,
5940 memory_context::UserIntentProof::ExplicitCommand {
5941 command_id: memory_context::mint_explicit_command_id(),
5942 },
5943 )
5944 .unwrap();
5945 let lease = runtime
5946 .memory_context_lock()
5947 .capture_lease_at(std::time::SystemTime::now())
5948 .unwrap();
5949 runtime.apply_config(&agent_core::config::load_config_from_str(
5950 "memory.backend = axel\n",
5951 ));
5952 assert_memory_off_no_lease(&runtime);
5953 assert!(runtime.extension_capture_provider(&lease).is_none());
5954 }
5955
5956 #[test]
5957 fn axel_invalid_config_cannot_grant_consent_or_fallback() {
5958 let mut runtime = Runtime::new_headless();
5959 runtime.apply_config(&agent_core::config::load_config_from_str(
5960 "memory.backend = invalid-not-axel-not-legacy\n",
5961 ));
5962 assert!(runtime.memory_backend.exclusive());
5963 assert!(runtime.resolve_memory_provider(None).is_err());
5964 assert_memory_off_no_lease(&runtime);
5965 }
5966
5967 #[tokio::test]
5973 async fn memory_enable_unregistered_provider_fails_closed_grants_nothing() {
5974 let (runtime, _ext) =
5976 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
5977 for requested in ["no-such-provider", "bad:id"] {
5978 let err = runtime
5979 .memory_context_enable_resolved(
5980 memory_context::MemoryContextMode::CaptureAndRecall,
5981 memory_context::mint_explicit_command_proof(),
5982 Some(requested),
5983 )
5984 .expect_err("unregistered provider must fail closed");
5985 assert_eq!(
5986 err,
5987 memory_context::MemoryContextError::ProviderNotRegistered
5988 );
5989 assert_memory_off_no_lease(&runtime);
5990 }
5991
5992 let (runtime, _ext) = memory_runtime_with_providers(&[]).await;
5995 let err = runtime
5996 .memory_context_enable(
5997 memory_context::MemoryContextMode::CaptureAndRecall,
5998 memory_context::mint_explicit_command_proof(),
5999 )
6000 .expect_err("empty catalog must fail closed");
6001 assert_eq!(
6002 err,
6003 memory_context::MemoryContextError::ProviderNotRegistered
6004 );
6005 assert_memory_off_no_lease(&runtime);
6006 let err = runtime
6008 .memory_context_recall_once(memory_context::mint_explicit_command_proof())
6009 .expect_err("one-shot grant must validate the provider too");
6010 assert_eq!(
6011 err,
6012 memory_context::MemoryContextError::ProviderNotRegistered
6013 );
6014 assert_memory_off_no_lease(&runtime);
6015 }
6016
6017 #[tokio::test]
6021 async fn memory_enable_exactly_one_declared_provider_records_exact_identity() {
6022 const ADDRESS: &str = "extension:axel-memory-manager:project-memory";
6023 let (runtime, _ext) =
6025 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
6026 let status = runtime
6027 .memory_context_enable(
6028 memory_context::MemoryContextMode::CaptureAndRecall,
6029 memory_context::mint_explicit_command_proof(),
6030 )
6031 .expect("unique declared provider must resolve");
6032 assert!(matches!(
6033 status.durable,
6034 memory_context::DurableStatus::Active { .. }
6035 ));
6036 let bound = runtime.memory_bound_providers_for_test();
6037 assert_eq!(bound.len(), 1);
6038 assert_eq!(bound[0].as_str(), ADDRESS, "exact runtime address recorded");
6039
6040 let (runtime, _ext) =
6042 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
6043 runtime
6044 .memory_context_enable_resolved(
6045 memory_context::MemoryContextMode::RecallEachPrompt,
6046 memory_context::mint_explicit_command_proof(),
6047 Some("project-memory"),
6048 )
6049 .expect("explicit exact id must resolve");
6050 assert_eq!(
6051 runtime.memory_bound_providers_for_test()[0].as_str(),
6052 ADDRESS
6053 );
6054 }
6055
6056 #[tokio::test]
6060 async fn memory_enable_ambiguous_overlapping_declarations_fails_closed() {
6061 let (runtime, _ext) = memory_runtime_with_providers(&[
6062 ("mem-a", "project-memory"),
6063 ("mem-b", "project-memory"),
6064 ])
6065 .await;
6066 let err = runtime
6067 .memory_context_enable(
6068 memory_context::MemoryContextMode::CaptureAndRecall,
6069 memory_context::mint_explicit_command_proof(),
6070 )
6071 .expect_err("overlapping declarations must fail closed");
6072 assert_eq!(err, memory_context::MemoryContextError::ProviderAmbiguous);
6073 assert_memory_off_no_lease(&runtime);
6074
6075 let err = runtime
6077 .memory_context_enable_resolved(
6078 memory_context::MemoryContextMode::CaptureOnly,
6079 memory_context::mint_explicit_command_proof(),
6080 Some("project-memory"),
6081 )
6082 .expect_err("same id declared by two plugins is ambiguous");
6083 assert_eq!(err, memory_context::MemoryContextError::ProviderAmbiguous);
6084 assert_memory_off_no_lease(&runtime);
6085 }
6086
6087 #[tokio::test]
6093 async fn memory_disable_revokes_runtime_lease_without_spawn_no_panic() {
6094 let (runtime, ext) =
6095 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
6096 runtime
6097 .memory_context_enable(
6098 memory_context::MemoryContextMode::CaptureAndRecall,
6099 memory_context::mint_explicit_command_proof(),
6100 )
6101 .expect("enable succeeds");
6102 assert_eq!(
6103 ext.lease_count(),
6104 0,
6105 "granting a memory lease spawns nothing"
6106 );
6107
6108 let status = runtime.memory_context_disable();
6109 assert_eq!(status.durable, memory_context::DurableStatus::Off);
6110 assert_memory_off_no_lease(&runtime);
6111 assert_eq!(
6112 ext.lease_count(),
6113 0,
6114 "revocation of a never-spawned lease is a no-op"
6115 );
6116
6117 let status = runtime.memory_context_disable();
6119 assert_eq!(status.durable, memory_context::DurableStatus::Off);
6120 assert_eq!(ext.lease_count(), 0);
6121 }
6122
6123 #[test]
6128 fn memory_context_enable_disable_emit_metadata_only_events() {
6129 memory_context::drain_captured_memory_events_for_test();
6130
6131 let runtime = Runtime::new_headless();
6132 runtime
6134 .memory_context_enable(
6135 memory_context::MemoryContextMode::Off,
6136 memory_context::mint_explicit_command_proof(),
6137 )
6138 .expect_err("Off is not an enable mode");
6139 assert!(
6140 memory_context::drain_captured_memory_events_for_test().is_empty(),
6141 "failed enable must emit no event"
6142 );
6143
6144 runtime
6145 .memory_context_enable(
6146 memory_context::MemoryContextMode::CaptureAndRecall,
6147 memory_context::mint_explicit_command_proof(),
6148 )
6149 .expect("enable succeeds");
6150 runtime.memory_context_disable();
6151
6152 let events = memory_context::drain_captured_memory_events_for_test();
6153 assert_eq!(
6154 events
6155 .iter()
6156 .map(|event| (event.event, event.outcome))
6157 .collect::<Vec<_>>(),
6158 vec![
6159 ("memory_context.enabled", "enabled"),
6160 ("memory_context.disabled", "disabled"),
6161 ],
6162 );
6163 assert_eq!(events[0].mode, Some("capture_and_recall"));
6164 assert_eq!(events[1].mode, Some("off"));
6165
6166 let cwd = std::env::current_dir()
6169 .expect("cwd")
6170 .to_string_lossy()
6171 .into_owned();
6172 for event in &events {
6173 let json = serde_json::to_string(event).expect("event serializes");
6174 assert!(
6175 json.contains("\"project_digest\":\"p") || json.contains("project-cwd-"),
6176 "digest identity expected; got: {json}"
6177 );
6178 assert!(
6179 !json.contains(&cwd),
6180 "raw project path leaked into event JSON: {json}"
6181 );
6182 }
6183 }
6184
6185 #[tokio::test]
6191 async fn memory_session_end_guard_drop_fresh_construction_is_off_no_lease() {
6192 let (runtime, ext) =
6193 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
6194 runtime
6195 .memory_context_enable(
6196 memory_context::MemoryContextMode::CaptureAndRecall,
6197 memory_context::mint_explicit_command_proof(),
6198 )
6199 .expect("enable succeeds");
6200 drop(runtime);
6202 assert_eq!(
6203 ext.lease_count(),
6204 0,
6205 "session end must leave no runtime lease (never-spawned reap is a no-op)"
6206 );
6207
6208 let mut fresh = Runtime::new_headless();
6210 fresh.install_extension_runtime(std::sync::Arc::clone(&ext));
6211 assert_memory_off_no_lease(&fresh);
6212 assert_eq!(ext.lease_count(), 0);
6213 }
6214}